From 6988e5b829211d6455975dd9893569efbe0841b3 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 29 Dec 2025 16:04:50 +0100 Subject: [PATCH 01/18] Add Middleware handlers to StreamableHttpTransport --- composer.json | 2 + docs/transports.md | 40 +++++ .../Transport/StreamableHttpTransport.php | 94 ++++++++-- .../Transport/StreamableHttpTransportTest.php | 164 ++++++++++++++++++ 4 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 tests/Unit/Server/Transport/StreamableHttpTransportTest.php diff --git a/composer.json b/composer.json index 83a08f39..b7d2483f 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,8 @@ "psr/event-dispatcher": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" diff --git a/docs/transports.md b/docs/transports.md index 290fd49c..a68875d9 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -179,6 +179,46 @@ Default CORS headers: - `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS` - `Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept` +### PSR-15 Middleware + +`StreamableHttpTransport` can run a PSR-15 middleware chain before it processes the request. Middleware can log, +enforce auth, or short-circuit with a response for any HTTP method. + +```php +use Mcp\Server\Transport\StreamableHttpTransport; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +final class AuthMiddleware implements MiddlewareInterface +{ + public function __construct(private ResponseFactoryInterface $responses) + { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) + { + if (!$request->hasHeader('Authorization')) { + return $this->responses->createResponse(401); + } + + return $handler->handle($request); + } +} + +$transport = new StreamableHttpTransport( + $request, + $responseFactory, + $streamFactory, + [], + $logger, + [new AuthMiddleware($responseFactory)], +); +``` + +If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself. + ### Architecture The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 2b1e6869..73c491d8 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -17,6 +17,8 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; @@ -36,19 +38,22 @@ class StreamableHttpTransport extends BaseTransport /** @var array */ private array $corsHeaders; + /** @var list */ + private array $middlewares = []; + /** * @param array $corsHeaders + * @param iterable $middlewares */ public function __construct( - private readonly ServerRequestInterface $request, + private ServerRequestInterface $request, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, array $corsHeaders = [], ?LoggerInterface $logger = null, + iterable $middlewares = [], ) { parent::__construct($logger); - $sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id'); - $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); @@ -59,6 +64,13 @@ public function __construct( 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept', 'Access-Control-Expose-Headers' => 'Mcp-Session-Id', ], $corsHeaders); + + foreach ($middlewares as $middleware) { + if (!$middleware instanceof MiddlewareInterface) { + throw new \InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.'); + } + $this->middlewares[] = $middleware; + } } public function send(string $data, array $context): void @@ -69,17 +81,15 @@ public function send(string $data, array $context): void public function listen(): ResponseInterface { - return match ($this->request->getMethod()) { - 'OPTIONS' => $this->handleOptionsRequest(), - 'POST' => $this->handlePostRequest(), - 'DELETE' => $this->handleDeleteRequest(), - default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), - }; + $handler = $this->createRequestHandler(); + $response = $handler->handle($this->request); + + return $this->withCorsHeaders($response); } protected function handleOptionsRequest(): ResponseInterface { - return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + return $this->responseFactory->createResponse(204); } protected function handlePostRequest(): ResponseInterface @@ -92,7 +102,7 @@ protected function handlePostRequest(): ResponseInterface ->withHeader('Content-Type', 'application/json') ->withBody($this->streamFactory->createStream($this->immediateResponse)); - return $this->withCorsHeaders($response); + return $response; } if (null !== $this->sessionFiber) { @@ -112,7 +122,7 @@ protected function handleDeleteRequest(): ResponseInterface $this->handleSessionEnd($this->sessionId); - return $this->withCorsHeaders($this->responseFactory->createResponse(200)); + return $this->responseFactory->createResponse(200); } protected function createJsonResponse(): ResponseInterface @@ -120,7 +130,7 @@ protected function createJsonResponse(): ResponseInterface $outgoingMessages = $this->getOutgoingMessages($this->sessionId); if (empty($outgoingMessages)) { - return $this->withCorsHeaders($this->responseFactory->createResponse(202)); + return $this->responseFactory->createResponse(202); } $messages = array_column($outgoingMessages, 'message'); @@ -134,7 +144,7 @@ protected function createJsonResponse(): ResponseInterface $response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122()); } - return $this->withCorsHeaders($response); + return $response; } protected function createStreamedResponse(): ResponseInterface @@ -201,7 +211,7 @@ protected function createStreamedResponse(): ResponseInterface $response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122()); } - return $this->withCorsHeaders($response); + return $response; } protected function handleFiberTermination(): void @@ -242,15 +252,65 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re ->withHeader('Content-Type', 'application/json') ->withBody($this->streamFactory->createStream($payload)); - return $this->withCorsHeaders($response); + return $response; } protected function withCorsHeaders(ResponseInterface $response): ResponseInterface { foreach ($this->corsHeaders as $name => $value) { - $response = $response->withHeader($name, $value); + if (!$response->hasHeader($name)) { + $response = $response->withHeader($name, $value); + } } return $response; } + + private function handleRequest(ServerRequestInterface $request): ResponseInterface + { + $this->request = $request; + $sessionIdString = $request->getHeaderLine('Mcp-Session-Id'); + $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; + + return match ($request->getMethod()) { + 'OPTIONS' => $this->handleOptionsRequest(), + 'POST' => $this->handlePostRequest(), + 'DELETE' => $this->handleDeleteRequest(), + default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), + }; + } + + private function createRequestHandler(): RequestHandlerInterface + { + /** + * @see self::handleRequest + */ + $handler = new class(\Closure::fromCallable([$this, 'handleRequest'])) implements RequestHandlerInterface { + public function __construct(private \Closure $handler) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return ($this->handler)($request); + } + }; + + foreach (array_reverse($this->middlewares) as $middleware) { + $handler = new class($middleware, $handler) implements RequestHandlerInterface { + public function __construct( + private MiddlewareInterface $middleware, + private RequestHandlerInterface $handler, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->middleware->process($request, $this->handler); + } + }; + } + + return $handler; + } } diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php new file mode 100644 index 00000000..51af6272 --- /dev/null +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -0,0 +1,164 @@ + ['GET', false, 401]; + yield 'POST (middleware returns 401)' => ['POST', false, 401]; + yield 'DELETE (middleware returns 401)' => ['DELETE', false, 401]; + yield 'OPTIONS (middleware delegates -> transport handles preflight)' => ['OPTIONS', true, 204]; + yield 'GET (middleware delegates -> transport handles preflight)' => ['GET', true, 405]; + yield 'POST (middleware delegates -> transport handles preflight)' => ['POST', true, 202]; + yield 'DELETE (middleware delegates -> transport handles preflight)' => ['DELETE', true, 400]; + } + + #[DataProvider('corsHeaderProvider')] + #[TestDox('CORS headers are always applied')] + public function testCorsHeader(string $method, bool $middlewareDelegatesToTransport, int $expectedStatusCode): void + { + $factory = new Psr17Factory(); + $request = $factory->createServerRequest($method, 'https://example.com'); + + $middleware = new class($factory, $expectedStatusCode, $middlewareDelegatesToTransport) implements MiddlewareInterface { + public function __construct( + private ResponseFactoryInterface $responseFactory, + private int $expectedStatusCode, + private bool $middlewareDelegatesToTransport, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->middlewareDelegatesToTransport) { + return $handler->handle($request); + } + + return $this->responseFactory->createResponse($this->expectedStatusCode); + } + }; + + $transport = new StreamableHttpTransport( + $request, + $factory, + $factory, + [], + null, + [$middleware], + ); + + $response = $transport->listen(); + + $this->assertSame($expectedStatusCode, $response->getStatusCode(), $response->getBody()->getContents()); + $this->assertTrue($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertTrue($response->hasHeader('Access-Control-Allow-Methods')); + $this->assertTrue($response->hasHeader('Access-Control-Allow-Headers')); + $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers')); + + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertSame('GET, POST, DELETE, OPTIONS', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame( + 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept', + $response->getHeaderLine('Access-Control-Allow-Headers') + ); + $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + } + + #[TestDox('transport replaces existing CORS headers on the response')] + public function testCorsHeadersAreReplacedWhenAlreadyPresent(): void + { + $factory = new Psr17Factory(); + $request = $factory->createServerRequest('GET', 'https://example.com'); + + $middleware = new class($factory) implements MiddlewareInterface { + public function __construct(private ResponseFactoryInterface $responses) + { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $this->responses->createResponse(200) + ->withHeader('Access-Control-Allow-Origin', 'https://another.com'); + } + }; + + $transport = new StreamableHttpTransport( + $request, + $factory, + $factory, + [], + null, + [$middleware], + ); + + $response = $transport->listen(); + + $this->assertSame(200, $response->getStatusCode()); + + $this->assertSame('https://another.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertSame('GET, POST, DELETE, OPTIONS', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertSame( + 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept', + $response->getHeaderLine('Access-Control-Allow-Headers') + ); + $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + } + + #[TestDox('middleware runs before transport handles the request')] + public function testMiddlewareRunsBeforeTransportHandlesRequest(): void + { + $factory = new Psr17Factory(); + $request = $factory->createServerRequest('OPTIONS', 'https://example.com'); + + $state = new \stdClass(); + $state->called = false; + $middleware = new class($state) implements MiddlewareInterface { + public function __construct(private \stdClass $state) + { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $this->state->called = true; + + return $handler->handle($request); + } + }; + + $transport = new StreamableHttpTransport( + $request, + $factory, + $factory, + [], + null, + [$middleware], + ); + + $response = $transport->listen(); + + $this->assertTrue($state->called); + $this->assertSame(204, $response->getStatusCode()); + } +} From 6ac6671d5f4bc855ea7d06bfc8fd3a7d2a0cf3bf Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 12 Jan 2026 12:40:25 +0100 Subject: [PATCH 02/18] OAuth Implementation based on middleware --- composer.json | 5 + examples/server/oauth-keycloak/Dockerfile | 23 + .../server/oauth-keycloak/McpElements.php | 109 +++++ examples/server/oauth-keycloak/README.md | 136 ++++++ .../server/oauth-keycloak/docker-compose.yml | 61 +++ .../oauth-keycloak/keycloak/mcp-realm.json | 128 ++++++ .../server/oauth-keycloak/nginx/default.conf | 25 ++ examples/server/oauth-keycloak/server.php | 96 +++++ examples/server/oauth-microsoft/Dockerfile | 23 + .../server/oauth-microsoft/McpElements.php | 126 ++++++ examples/server/oauth-microsoft/README.md | 213 +++++++++ .../server/oauth-microsoft/docker-compose.yml | 32 ++ examples/server/oauth-microsoft/env.example | 18 + .../server/oauth-microsoft/nginx/default.conf | 25 ++ examples/server/oauth-microsoft/server.php | 110 +++++ .../Middleware/AuthorizationMiddleware.php | 304 +++++++++++++ .../Middleware/AuthorizationResult.php | 138 ++++++ .../AuthorizationTokenValidatorInterface.php | 35 ++ .../Middleware/JwtTokenValidator.php | 407 ++++++++++++++++++ .../Middleware/OAuthProxyMiddleware.php | 234 ++++++++++ .../Transport/Middleware/OidcDiscovery.php | 291 +++++++++++++ .../Middleware/ProtectedResourceMetadata.php | 88 ++++ 22 files changed, 2627 insertions(+) create mode 100644 examples/server/oauth-keycloak/Dockerfile create mode 100644 examples/server/oauth-keycloak/McpElements.php create mode 100644 examples/server/oauth-keycloak/README.md create mode 100644 examples/server/oauth-keycloak/docker-compose.yml create mode 100644 examples/server/oauth-keycloak/keycloak/mcp-realm.json create mode 100644 examples/server/oauth-keycloak/nginx/default.conf create mode 100644 examples/server/oauth-keycloak/server.php create mode 100644 examples/server/oauth-microsoft/Dockerfile create mode 100644 examples/server/oauth-microsoft/McpElements.php create mode 100644 examples/server/oauth-microsoft/README.md create mode 100644 examples/server/oauth-microsoft/docker-compose.yml create mode 100644 examples/server/oauth-microsoft/env.example create mode 100644 examples/server/oauth-microsoft/nginx/default.conf create mode 100644 examples/server/oauth-microsoft/server.php create mode 100644 src/Server/Transport/Middleware/AuthorizationMiddleware.php create mode 100644 src/Server/Transport/Middleware/AuthorizationResult.php create mode 100644 src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php create mode 100644 src/Server/Transport/Middleware/JwtTokenValidator.php create mode 100644 src/Server/Transport/Middleware/OAuthProxyMiddleware.php create mode 100644 src/Server/Transport/Middleware/OidcDiscovery.php create mode 100644 src/Server/Transport/Middleware/ProtectedResourceMetadata.php diff --git a/composer.json b/composer.json index b7d2483f..49919041 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", @@ -35,6 +36,8 @@ "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "require-dev": { + "firebase/php-jwt": "^6.10", + "guzzlehttp/guzzle": "^7.0", "laminas/laminas-httphandlerrunner": "^2.12", "nyholm/psr7": "^1.8", "nyholm/psr7-server": "^1.1", @@ -65,6 +68,8 @@ "Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/", "Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/", "Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/", + "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", + "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Tests\\": "tests/" } diff --git a/examples/server/oauth-keycloak/Dockerfile b/examples/server/oauth-keycloak/Dockerfile new file mode 100644 index 00000000..34b5d540 --- /dev/null +++ b/examples/server/oauth-keycloak/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.2-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + curl \ + git \ + unzip + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Install PHP extensions +RUN docker-php-ext-install opcache + +# Configure PHP-FPM to listen on TCP +RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/examples/server/oauth-keycloak/McpElements.php b/examples/server/oauth-keycloak/McpElements.php new file mode 100644 index 00000000..d2b07562 --- /dev/null +++ b/examples/server/oauth-keycloak/McpElements.php @@ -0,0 +1,109 @@ + true, + 'message' => 'You have successfully authenticated with OAuth!', + 'timestamp' => date('c'), + 'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.', + ]; + } + + /** + * Simulates calling a protected external API. + */ + #[McpTool( + name: 'call_protected_api', + description: 'Simulate calling a protected external API endpoint' + )] + public function callProtectedApi( + string $endpoint, + string $method = 'GET', + ): array { + // In a real implementation, you would: + // 1. Use token exchange to get a token for the downstream API + // 2. Or use client credentials with the user's context + // 3. Make the actual HTTP call to the protected API + + return [ + 'status' => 'success', + 'message' => sprintf('Simulated %s request to %s', $method, $endpoint), + 'simulated_response' => [ + 'data' => 'This is simulated data from the protected API', + 'timestamp' => date('c'), + ], + ]; + } + + /** + * Returns the current server time and status. + */ + #[McpResource( + uri: 'server://status', + name: 'server_status', + description: 'Current server status (protected resource)', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'healthy', + 'timestamp' => date('c'), + 'php_version' => PHP_VERSION, + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'protected' => true, + ]; + } + + /** + * A greeting prompt. + */ + #[McpPrompt( + name: 'greeting', + description: 'Generate a greeting message' + )] + public function greeting(string $style = 'formal'): string + { + return match ($style) { + 'casual' => "Hey there! Welcome to the protected MCP server!", + 'formal' => "Good day. Welcome to the OAuth-protected MCP server.", + 'friendly' => "Hello! Great to have you here!", + default => "Welcome to the MCP server!", + }; + } +} diff --git a/examples/server/oauth-keycloak/README.md b/examples/server/oauth-keycloak/README.md new file mode 100644 index 00000000..fb3029be --- /dev/null +++ b/examples/server/oauth-keycloak/README.md @@ -0,0 +1,136 @@ +# OAuth Keycloak Example + +This example demonstrates MCP server authorization using Keycloak as the OAuth 2.0 / OpenID Connect provider. + +## Features + +- JWT token validation with automatic JWKS discovery +- Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource` +- MCP tools protected by OAuth authentication +- Pre-configured Keycloak realm with test user + +## Quick Start + +1. **Start the services:** + +```bash +docker compose up -d +``` + +2. **Wait for Keycloak to be ready** (may take 30-60 seconds): + +```bash +docker compose logs -f keycloak +# Wait until you see "Running the server in development mode" +``` + +3. **Get an access token:** + +```bash +# Using Resource Owner Password Credentials (for testing only) +TOKEN=$(curl -s -X POST "http://localhost:8180/realms/mcp/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=mcp-client" \ + -d "username=demo" \ + -d "password=demo123" \ + -d "grant_type=password" \ + -d "scope=openid mcp" | jq -r '.access_token') + +echo $TOKEN +``` + +4. **Test the MCP server:** + +```bash +# Get Protected Resource Metadata +curl http://localhost:8000/.well-known/oauth-protected-resource + +# Call MCP endpoint without token (should get 401) +curl -i http://localhost:8000/mcp + +# Call MCP endpoint with token +curl -X POST http://localhost:8000/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +5. **Use with MCP Inspector:** + +The MCP Inspector doesn't support OAuth out of the box, but you can test using curl or build a custom client. + +## Keycloak Configuration + +The realm is pre-configured with: + +| Item | Value | +|------|-------| +| Realm | `mcp` | +| Client (public) | `mcp-client` | +| Client (resource) | `mcp-server` | +| Test User | `demo` / `demo123` | +| Scopes | `mcp:read`, `mcp:write` | + +### Keycloak Admin Console + +Access at http://localhost:8180/admin with: +- Username: `admin` +- Password: `admin` + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │ +│ │ │ (port 8000) │ │ MCP Server │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ Get Token │ Validate JWT + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Keycloak │◀───────────────────────────│ JWKS Fetch │ +│ (port 8180) │ │ │ +└─────────────────┘ └─────────────────┘ +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration +- `Dockerfile` - PHP-FPM container with dependencies +- `nginx/default.conf` - Nginx configuration for MCP endpoint +- `keycloak/mcp-realm.json` - Pre-configured Keycloak realm +- `server.php` - MCP server with OAuth middleware +- `McpElements.php` - MCP tools and resources + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `KEYCLOAK_EXTERNAL_URL` | `http://localhost:8180` | Keycloak URL as seen by clients (token issuer) | +| `KEYCLOAK_INTERNAL_URL` | `http://keycloak:8080` | Keycloak URL from within Docker network (for JWKS) | +| `KEYCLOAK_REALM` | `mcp` | Keycloak realm name | +| `MCP_AUDIENCE` | `mcp-server` | Expected JWT audience | + +## Troubleshooting + +### Token validation fails + +1. Ensure Keycloak is fully started (check health endpoint) +2. Verify the token hasn't expired (default: 5 minutes) +3. Check that the audience claim matches `mcp-server` + +### Connection refused + +1. Wait for Keycloak health check to pass +2. Check Docker network connectivity: `docker compose logs` + +### JWKS fetch fails + +The MCP server needs to reach Keycloak at `http://keycloak:8080` (Docker network). +For local development outside Docker, use `http://localhost:8180`. + +## Cleanup + +```bash +docker compose down -v +``` diff --git a/examples/server/oauth-keycloak/docker-compose.yml b/examples/server/oauth-keycloak/docker-compose.yml new file mode 100644 index 00000000..2dca5b2c --- /dev/null +++ b/examples/server/oauth-keycloak/docker-compose.yml @@ -0,0 +1,61 @@ +services: + keycloak: + image: quay.io/keycloak/keycloak:24.0 + container_name: mcp-keycloak + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + volumes: + - ./keycloak/mcp-realm.json:/opt/keycloak/data/import/mcp-realm.json:ro + command: + - start-dev + - --import-realm + ports: + - "8180:8080" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s + networks: + - mcp-network + + php: + build: + context: . + dockerfile: Dockerfile + container_name: mcp-php + volumes: + - ../../../:/app + working_dir: /app + environment: + KEYCLOAK_EXTERNAL_URL: http://localhost:8180 + KEYCLOAK_INTERNAL_URL: http://keycloak:8080 + KEYCLOAK_REALM: mcp + MCP_AUDIENCE: mcp-server + depends_on: + keycloak: + condition: service_healthy + command: > + sh -c "composer install --no-interaction --quiet 2>/dev/null || true && php-fpm" + networks: + - mcp-network + + nginx: + image: nginx:alpine + container_name: mcp-nginx + ports: + - "8000:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../../../:/app:ro + depends_on: + - php + networks: + - mcp-network + +networks: + mcp-network: + driver: bridge diff --git a/examples/server/oauth-keycloak/keycloak/mcp-realm.json b/examples/server/oauth-keycloak/keycloak/mcp-realm.json new file mode 100644 index 00000000..55d28751 --- /dev/null +++ b/examples/server/oauth-keycloak/keycloak/mcp-realm.json @@ -0,0 +1,128 @@ +{ + "realm": "mcp", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "clients": [ + { + "clientId": "mcp-client", + "name": "MCP Client Application", + "description": "Public client for MCP client applications", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "fullScopeAllowed": true, + "redirectUris": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "webOrigins": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "defaultClientScopes": [ + "openid", + "profile", + "email", + "mcp" + ], + "optionalClientScopes": [], + "attributes": { + "pkce.code.challenge.method": "S256" + } + }, + { + "clientId": "mcp-server", + "name": "MCP Server Resource", + "description": "Resource server representing the MCP server", + "enabled": true, + "publicClient": false, + "bearerOnly": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false + } + ], + "clientScopes": [ + { + "name": "mcp", + "description": "MCP access scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Access to MCP server resources" + }, + "protocolMappers": [ + { + "name": "mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "mcp-server", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "mcp-scopes", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "scope", + "claim.value": "mcp:read mcp:write", + "jsonType.label": "String", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ] + } + ], + "users": [ + { + "username": "demo", + "email": "demo@example.com", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "demo123", + "temporary": false + } + ], + "realmRoles": ["default-roles-mcp"] + } + ], + "defaultDefaultClientScopes": [ + "openid", + "profile", + "email" + ], + "roles": { + "realm": [ + { + "name": "default-roles-mcp", + "description": "Default roles for MCP realm", + "composite": false + } + ] + } +} diff --git a/examples/server/oauth-keycloak/nginx/default.conf b/examples/server/oauth-keycloak/nginx/default.conf new file mode 100644 index 00000000..f7a265ad --- /dev/null +++ b/examples/server/oauth-keycloak/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /app/examples/server/oauth-keycloak; + + # Route all requests through PHP + location / { + try_files $uri /server.php$is_args$args; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index server.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Pass all request info + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + } +} diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php new file mode 100644 index 00000000..f3f8b05f --- /dev/null +++ b/examples/server/oauth-keycloak/server.php @@ -0,0 +1,96 @@ +createServerRequestFromGlobals(); + +// Create JWT validator +// - issuer: matches what's in the token (external URL) +// - jwksUri: where to fetch keys (internal URL) +$validator = new JwtTokenValidator( + issuer: $issuer, + audience: $mcpAudience, + jwksUri: $jwksUri, +); + +// Create Protected Resource Metadata (RFC 9728) +// Authorization server URL should be the external URL for clients +// scopesSupported must match what Keycloak's mcp-client allows +$metadata = new ProtectedResourceMetadata( + authorizationServers: [$issuer], + scopesSupported: ['openid'], + resource: 'http://localhost:8000/mcp', +); + +// Create authorization middleware +$authMiddleware = new AuthorizationMiddleware( + metadata: $metadata, + validator: $validator, + metadataPaths: ['/.well-known/oauth-protected-resource'], +); + +// Build MCP server +$server = Server::builder() + ->setServerInfo('OAuth Keycloak Example', '1.0.0') + ->setLogger($logger) + ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setDiscovery(__DIR__) + ->build(); + +// Create transport with authorization middleware +$transport = new StreamableHttpTransport( + $request, + logger: $logger, + middlewares: [$authMiddleware], +); + +// Run server +$response = $server->run($transport); + +// Emit response +(new SapiEmitter())->emit($response); diff --git a/examples/server/oauth-microsoft/Dockerfile b/examples/server/oauth-microsoft/Dockerfile new file mode 100644 index 00000000..34b5d540 --- /dev/null +++ b/examples/server/oauth-microsoft/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.2-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + curl \ + git \ + unzip + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Install PHP extensions +RUN docker-php-ext-install opcache + +# Configure PHP-FPM to listen on TCP +RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/examples/server/oauth-microsoft/McpElements.php b/examples/server/oauth-microsoft/McpElements.php new file mode 100644 index 00000000..c2d27f7c --- /dev/null +++ b/examples/server/oauth-microsoft/McpElements.php @@ -0,0 +1,126 @@ + true, + 'provider' => 'Microsoft Entra ID', + 'message' => 'You have successfully authenticated with Microsoft!', + 'timestamp' => date('c'), + ]; + } + + /** + * Simulates calling Microsoft Graph API. + */ + #[McpTool( + name: 'call_graph_api', + description: 'Simulate calling Microsoft Graph API' + )] + public function callGraphApi( + string $endpoint = '/me', + ): array { + // In a real implementation, you would: + // 1. Use the On-Behalf-Of flow to exchange tokens + // 2. Call Microsoft Graph with the new token + + return [ + 'status' => 'simulated', + 'endpoint' => "https://graph.microsoft.com/v1.0{$endpoint}", + 'message' => 'Configure AZURE_CLIENT_SECRET for actual Graph API calls', + 'simulated_response' => [ + 'displayName' => 'Demo User', + 'mail' => 'demo@example.com', + ], + ]; + } + + /** + * Lists simulated emails. + */ + #[McpTool( + name: 'list_emails', + description: 'List recent emails (simulated)' + )] + public function listEmails(int $count = 5): array + { + return [ + 'note' => 'Simulated data. Implement Graph API call with Mail.Read scope for real emails.', + 'emails' => array_map(fn ($i) => [ + 'id' => 'msg_'.uniqid(), + 'subject' => "Sample Email #{$i}", + 'from' => "sender{$i}@example.com", + 'receivedDateTime' => date('c', strtotime("-{$i} hours")), + ], range(1, $count)), + ]; + } + + /** + * Returns the current server status. + */ + #[McpResource( + uri: 'server://status', + name: 'server_status', + description: 'Current server status with Microsoft auth info', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'healthy', + 'timestamp' => date('c'), + 'auth_provider' => 'Microsoft Entra ID', + 'php_version' => PHP_VERSION, + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + ]; + } + + /** + * A Microsoft Teams-style message prompt. + */ + #[McpPrompt( + name: 'teams_message', + description: 'Generate a Microsoft Teams-style message' + )] + public function teamsMessage(string $messageType = 'announcement'): string + { + return match ($messageType) { + 'announcement' => "📢 **Announcement**\n\nPlease add your announcement content here.", + 'question' => "❓ **Question**\n\nType your question here.", + 'update' => "📋 **Status Update**\n\n**Progress:**\n- Item 1\n- Item 2", + default => "💬 **Message**\n\nYour message content here.", + }; + } +} diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md new file mode 100644 index 00000000..0a98121f --- /dev/null +++ b/examples/server/oauth-microsoft/README.md @@ -0,0 +1,213 @@ +# OAuth Microsoft Entra ID Example + +This example demonstrates MCP server authorization using Microsoft Entra ID (formerly Azure AD) as the OAuth 2.0 / OpenID Connect provider. + +## Features + +- JWT token validation with Microsoft Entra ID +- Protected Resource Metadata (RFC 9728) +- MCP tools that access Microsoft claims +- Optional Microsoft Graph API integration + +## Prerequisites + +1. **Azure Subscription** with access to Entra ID +2. **App Registration** in Azure Portal + +## Azure Setup + +### 1. Create App Registration + +1. Go to [Azure Portal](https://portal.azure.com) > **Entra ID** > **App registrations** +2. Click **New registration** +3. Configure: + - **Name**: `MCP Server` + - **Supported account types**: Choose based on your needs + - **Redirect URI**: Leave empty for now (this is a resource server) +4. Click **Register** + +### 2. Configure the App + +After registration: + +1. **Copy values for `.env`**: + - **Application (client) ID** → `AZURE_CLIENT_ID` + - **Directory (tenant) ID** → `AZURE_TENANT_ID` + +2. **Expose an API** (optional, for custom scopes): + - Go to **Expose an API** + - Set **Application ID URI** (e.g., `api://your-client-id`) + - Add scopes like `mcp.read`, `mcp.write` + +3. **Create client secret** (for Graph API calls): + - Go to **Certificates & secrets** + - Click **New client secret** + - Copy the secret value → `AZURE_CLIENT_SECRET` + +4. **API Permissions** (for Graph API): + - Go to **API permissions** + - Add **Microsoft Graph** > **Delegated permissions**: + - `User.Read` (for profile) + - `Mail.Read` (for emails, optional) + - Grant admin consent if required + +### 3. Create a Client App (for testing) + +Create a separate app registration for the client: + +1. **New registration**: + - **Name**: `MCP Client` + - **Redirect URI**: `http://localhost` (Public client/native) + +2. **Authentication**: + - Enable **Allow public client flows** for PKCE + +3. **API permissions**: + - Add permission to your MCP Server app's exposed API + +## Quick Start + +1. **Copy environment file:** + +```bash +cp env.example .env +``` + +2. **Edit `.env` with your Azure values:** + +```bash +AZURE_TENANT_ID=your-tenant-id +AZURE_CLIENT_ID=your-client-id +AZURE_CLIENT_SECRET=your-client-secret # Optional, for Graph API +``` + +3. **Start the services:** + +```bash +docker compose up -d +``` + +4. **Get an access token:** + +Using Azure CLI: +```bash +# Login +az login + +# Get token for your app +TOKEN=$(az account get-access-token \ + --resource api://your-client-id \ + --query accessToken -o tsv) +``` + +Or using MSAL / OAuth flow in your client application. + +5. **Test the MCP server:** + +```bash +# Get Protected Resource Metadata +curl http://localhost:8000/.well-known/oauth-protected-resource + +# Call MCP endpoint without token (should get 401) +curl -i http://localhost:8000/mcp + +# Call MCP endpoint with token +curl -X POST http://localhost:8000/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │ +│ │ │ (port 8000) │ │ MCP Server │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ Get Token │ Validate JWT + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Microsoft │◀───────────────────────────│ JWKS Fetch │ +│ Entra ID │ │ │ +└─────────────────┘ └─────────────────┘ + │ + │ (Optional) Graph API + ▼ +┌─────────────────┐ +│ Microsoft │ +│ Graph API │ +└─────────────────┘ +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration +- `Dockerfile` - PHP-FPM container +- `nginx/default.conf` - Nginx configuration +- `env.example` - Environment variables template +- `server.php` - MCP server with OAuth middleware +- `McpElements.php` - MCP tools including Graph API integration + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `AZURE_TENANT_ID` | Yes | Azure AD tenant ID | +| `AZURE_CLIENT_ID` | Yes | Application (client) ID | +| `AZURE_CLIENT_SECRET` | No | Client secret for Graph API calls | + +## Microsoft Token Structure + +Microsoft Entra ID tokens include these common claims: + +| Claim | Description | +|-------|-------------| +| `oid` | Object ID (unique user identifier in tenant) | +| `tid` | Tenant ID | +| `sub` | Subject (unique user identifier) | +| `name` | Display name | +| `preferred_username` | Usually the UPN | +| `email` | Email address (if available) | +| `upn` | User Principal Name | + +## Troubleshooting + +### "Invalid issuer" error + +Microsoft uses different issuer URLs depending on the token flow: +- v2.0 endpoint (user/delegated flows): `https://login.microsoftonline.com/{tenant}/v2.0` +- v1.0 endpoint (client credentials flow): `https://sts.windows.net/{tenant}/` + +This example **automatically accepts both formats** by configuring multiple issuers in the `JwtTokenValidator`. +Check your token's `iss` claim to verify which format is being used. + +### "Invalid audience" error + +The `aud` claim must match `AZURE_CLIENT_ID`. For v2.0 tokens with custom scopes, +the audience might be `api://your-client-id`. + +### JWKS fetch fails + +Microsoft's JWKS endpoint is public. Ensure your container can reach: +`https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys` + +### Graph API errors + +1. Ensure `AZURE_CLIENT_SECRET` is set +2. Verify API permissions have admin consent +3. Check that the user exists in your tenant + +## Security Notes + +1. **Never commit `.env` files** - they contain secrets +2. **Use managed identities** in Azure deployments instead of client secrets +3. **Implement proper token refresh** in production clients +4. **Validate scopes** for sensitive operations + +## Cleanup + +```bash +docker compose down -v +``` diff --git a/examples/server/oauth-microsoft/docker-compose.yml b/examples/server/oauth-microsoft/docker-compose.yml new file mode 100644 index 00000000..c4312d71 --- /dev/null +++ b/examples/server/oauth-microsoft/docker-compose.yml @@ -0,0 +1,32 @@ +services: + php: + build: + context: . + dockerfile: Dockerfile + container_name: mcp-php-microsoft + volumes: + - ../../../:/app:ro + - ./server.php:/app/examples/server/oauth-microsoft/server.php:ro + - ./McpElements.php:/app/examples/server/oauth-microsoft/McpElements.php:ro + environment: + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + networks: + - mcp-network + + nginx: + image: nginx:alpine + container_name: mcp-nginx-microsoft + ports: + - "8000:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - php + networks: + - mcp-network + +networks: + mcp-network: + driver: bridge diff --git a/examples/server/oauth-microsoft/env.example b/examples/server/oauth-microsoft/env.example new file mode 100644 index 00000000..7ce041f4 --- /dev/null +++ b/examples/server/oauth-microsoft/env.example @@ -0,0 +1,18 @@ +# Microsoft Entra ID (Azure AD) Configuration +# Copy this file to .env and fill in your values + +# Your Azure AD tenant ID +# Find at: Azure Portal > Entra ID > Overview > Tenant ID +AZURE_TENANT_ID=your-tenant-id-here + +# Application (client) ID for the MCP server app registration +# This is the audience that tokens must be issued for +AZURE_CLIENT_ID=your-client-id-here + +# Client secret for calling Microsoft Graph API (optional) +# Only needed if your MCP tools call Graph API on behalf of users +AZURE_CLIENT_SECRET=your-client-secret-here + +# Optional: Specific API permissions/scopes your MCP server accepts +# Comma-separated list of custom scopes defined in your app registration +# MCP_SCOPES=api://your-client-id/mcp.read,api://your-client-id/mcp.write diff --git a/examples/server/oauth-microsoft/nginx/default.conf b/examples/server/oauth-microsoft/nginx/default.conf new file mode 100644 index 00000000..ad990152 --- /dev/null +++ b/examples/server/oauth-microsoft/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /app/examples/server/oauth-microsoft; + + # Route all requests through PHP + location / { + try_files $uri /server.php$is_args$args; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index server.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Pass all request info + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + } +} diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php new file mode 100644 index 00000000..da5c5cc7 --- /dev/null +++ b/examples/server/oauth-microsoft/server.php @@ -0,0 +1,110 @@ +createServerRequestFromGlobals(); + +// Create JWT validator for Microsoft Entra ID +// Microsoft uses the client ID as the audience for access tokens +// Accept both v1.0 and v2.0 issuers to support various token flows +$validator = new JwtTokenValidator( + issuer: $issuers, + audience: $clientId, + // Microsoft's JWKS endpoint - use common endpoint for all Microsoft signing keys + jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys', +); + +// Create Protected Resource Metadata (RFC 9728) +// Point to local authorization server (which proxies to Microsoft) +// This allows mcp-remote to use our /authorize and /token endpoints +$metadata = new ProtectedResourceMetadata( + authorizationServers: ['http://localhost:8000'], + scopesSupported: ['openid', 'profile', 'email'], + resource: null, +); + +// Get client secret for confidential client flow +$clientSecret = getenv('AZURE_CLIENT_SECRET') ?: null; + +// Create OAuth proxy middleware to handle /authorize and /token endpoints +// This proxies OAuth requests to Microsoft Entra ID +// The clientSecret is injected server-side since mcp-remote doesn't have access to it +$oauthProxyMiddleware = new OAuthProxyMiddleware( + upstreamIssuer: $issuerV2, + localBaseUrl: 'http://localhost:8000', + clientSecret: $clientSecret, +); + +// Create authorization middleware +$authMiddleware = new AuthorizationMiddleware( + metadata: $metadata, + validator: $validator, + metadataPaths: ['/.well-known/oauth-protected-resource'], +); + +// Build MCP server +$server = Server::builder() + ->setServerInfo('OAuth Microsoft Example', '1.0.0') + ->setLogger($logger) + ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setDiscovery(__DIR__) + ->build(); + +// Create transport with OAuth proxy and authorization middlewares +// Middlewares are reversed internally, so put OAuth proxy FIRST to execute FIRST +$transport = new StreamableHttpTransport( + $request, + logger: $logger, + middlewares: [$oauthProxyMiddleware, $authMiddleware], +); + +// Run server +$response = $server->run($transport); + +// Emit response +(new SapiEmitter())->emit($response); diff --git a/src/Server/Transport/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Middleware/AuthorizationMiddleware.php new file mode 100644 index 00000000..6bf6319b --- /dev/null +++ b/src/Server/Transport/Middleware/AuthorizationMiddleware.php @@ -0,0 +1,304 @@ + + */ +final class AuthorizationMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + /** @var list */ + private array $metadataPaths; + + /** @var callable(ServerRequestInterface): list|null */ + private $scopeProvider; + + /** + * @param ProtectedResourceMetadata $metadata The protected resource metadata to serve + * @param AuthorizationTokenValidatorInterface $validator Token validator implementation + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * @param list $metadataPaths Paths where metadata should be served (e.g., ["/.well-known/oauth-protected-resource"]) + * @param string|null $resourceMetadataUrl Explicit URL for the resource_metadata in WWW-Authenticate + * @param callable(ServerRequestInterface): list|null $scopeProvider Optional callback to determine required scopes per request + */ + public function __construct( + private ProtectedResourceMetadata $metadata, + private AuthorizationTokenValidatorInterface $validator, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + array $metadataPaths = [], + private ?string $resourceMetadataUrl = null, + ?callable $scopeProvider = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + + $this->metadataPaths = $this->normalizePaths($metadataPaths); + $this->scopeProvider = $scopeProvider; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // Serve metadata at well-known paths + if ($this->isMetadataRequest($request)) { + return $this->createMetadataResponse(); + } + + // Extract Authorization header + $authorization = $request->getHeaderLine('Authorization'); + if ('' === $authorization) { + return $this->buildErrorResponse($request, AuthorizationResult::unauthorized()); + } + + // Parse Bearer token + $accessToken = $this->parseBearerToken($authorization); + if (null === $accessToken) { + return $this->buildErrorResponse( + $request, + AuthorizationResult::badRequest('invalid_request', 'Malformed Authorization header.'), + ); + } + + // Validate the token + $result = $this->validator->validate($request, $accessToken); + if ($result->isAllowed()) { + return $handler->handle($this->applyAttributes($request, $result->getAttributes())); + } + + return $this->buildErrorResponse($request, $result); + } + + private function createMetadataResponse(): ResponseInterface + { + $payload = $this->metadata->toJson(); + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); + } + + private function isMetadataRequest(ServerRequestInterface $request): bool + { + if (empty($this->metadataPaths)) { + return false; + } + + if ('GET' !== $request->getMethod()) { + return false; + } + + $path = $request->getUri()->getPath(); + + foreach ($this->metadataPaths as $metadataPath) { + if ($path === $metadataPath) { + return true; + } + } + + return false; + } + + private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface + { + $response = $this->responseFactory->createResponse($result->getStatusCode()); + $header = $this->buildAuthenticateHeader($request, $result); + + if (null !== $header) { + $response = $response->withHeader('WWW-Authenticate', $header); + } + + return $response; + } + + private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): ?string + { + $parts = []; + + // Include resource_metadata URL per RFC 9728 + $resourceMetadataUrl = $this->resolveResourceMetadataUrl($request); + if (null !== $resourceMetadataUrl) { + $parts[] = 'resource_metadata="' . $this->escapeHeaderValue($resourceMetadataUrl) . '"'; + } + + // Include scope hint per RFC 6750 Section 3 + $scopes = $this->resolveScopes($request, $result); + if (!empty($scopes)) { + $parts[] = 'scope="' . $this->escapeHeaderValue(implode(' ', $scopes)) . '"'; + } + + // Include error details + if (null !== $result->getError()) { + $parts[] = 'error="' . $this->escapeHeaderValue($result->getError()) . '"'; + } + + if (null !== $result->getErrorDescription()) { + $parts[] = 'error_description="' . $this->escapeHeaderValue($result->getErrorDescription()) . '"'; + } + + if (empty($parts)) { + return 'Bearer'; + } + + return 'Bearer ' . implode(', ', $parts); + } + + /** + * @return list|null + */ + private function resolveScopes(ServerRequestInterface $request, AuthorizationResult $result): ?array + { + // First, check if the result has specific scopes (e.g., from insufficient_scope error) + $scopes = $this->normalizeScopes($result->getScopes()); + if (null !== $scopes) { + return $scopes; + } + + // Then, check the scope provider callback + if (null !== $this->scopeProvider) { + $provided = ($this->scopeProvider)($request); + $scopes = $this->normalizeScopes($provided); + if (null !== $scopes) { + return $scopes; + } + } + + // Fall back to scopes from metadata + return $this->normalizeScopes($this->metadata->getScopesSupported()); + } + + /** + * @param list|null $scopes + * + * @return list|null + */ + private function normalizeScopes(?array $scopes): ?array + { + if (null === $scopes) { + return null; + } + + $normalized = array_values(array_filter(array_map('trim', $scopes), static function (string $scope): bool { + return '' !== $scope; + })); + + return empty($normalized) ? null : $normalized; + } + + private function resolveResourceMetadataUrl(ServerRequestInterface $request): ?string + { + // Use explicit URL if configured + if (null !== $this->resourceMetadataUrl) { + return $this->resourceMetadataUrl; + } + + // Auto-generate from request if metadata paths are configured + if (empty($this->metadataPaths)) { + return null; + } + + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + $host = $uri->getHost(); + + if ('' === $scheme || '' === $host) { + return null; + } + + $authority = $host; + $port = $uri->getPort(); + + if (null !== $port && !$this->isDefaultPort($scheme, $port)) { + $authority .= ':' . $port; + } + + return $scheme . '://' . $authority . $this->metadataPaths[0]; + } + + private function isDefaultPort(string $scheme, int $port): bool + { + return ('https' === $scheme && 443 === $port) || ('http' === $scheme && 80 === $port); + } + + /** + * @param array $attributes + */ + private function applyAttributes(ServerRequestInterface $request, array $attributes): ServerRequestInterface + { + foreach ($attributes as $name => $value) { + $request = $request->withAttribute($name, $value); + } + + return $request; + } + + /** + * @param list $paths + * + * @return list + */ + private function normalizePaths(array $paths): array + { + $normalized = []; + + foreach ($paths as $path) { + $path = trim($path); + if ('' === $path) { + continue; + } + if ('/' !== $path[0]) { + $path = '/' . $path; + } + $normalized[] = $path; + } + + return array_values(array_unique($normalized)); + } + + private function parseBearerToken(string $authorization): ?string + { + if (!preg_match('/^Bearer\\s+(.+)$/i', $authorization, $matches)) { + return null; + } + + $token = trim($matches[1]); + + return '' === $token ? null : $token; + } + + private function escapeHeaderValue(string $value): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + } +} diff --git a/src/Server/Transport/Middleware/AuthorizationResult.php b/src/Server/Transport/Middleware/AuthorizationResult.php new file mode 100644 index 00000000..3750b17d --- /dev/null +++ b/src/Server/Transport/Middleware/AuthorizationResult.php @@ -0,0 +1,138 @@ + + */ +class AuthorizationResult +{ + /** + * @param list|null $scopes Scopes to include in WWW-Authenticate challenge + * @param array $attributes Attributes to attach to the request on success + */ + private function __construct( + private readonly bool $allowed, + private readonly int $statusCode, + private readonly ?string $error, + private readonly ?string $errorDescription, + private readonly ?array $scopes, + private readonly array $attributes, + ) { + } + + /** + * Creates a result indicating access is allowed. + * + * @param array $attributes Attributes to attach to the request (e.g., user_id, scopes) + */ + public static function allow(array $attributes = []): self + { + return new self(true, 200, null, null, null, $attributes); + } + + /** + * Creates a result indicating the request is unauthorized (401). + * + * Use when no valid credentials are provided or the token is invalid. + * + * @param string|null $error OAuth error code (e.g., "invalid_token") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge + */ + public static function unauthorized( + ?string $error = null, + ?string $errorDescription = null, + ?array $scopes = null, + ): self + { + return new self(false, 401, $error, $errorDescription, $scopes, []); + } + + /** + * Creates a result indicating the request is forbidden (403). + * + * Use when the token is valid but lacks required permissions/scopes. + * + * @param string|null $error OAuth error code (defaults to "insufficient_scope") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge + */ + public static function forbidden( + ?string $error = 'insufficient_scope', + ?string $errorDescription = null, + ?array $scopes = null, + ): self + { + return new self(false, 403, $error ?? 'insufficient_scope', $errorDescription, $scopes, []); + } + + /** + * Creates a result indicating a bad request (400). + * + * Use when the Authorization header is malformed. + * + * @param string|null $error OAuth error code (defaults to "invalid_request") + * @param string|null $errorDescription Human-readable error description + */ + public static function badRequest( + ?string $error = 'invalid_request', + ?string $errorDescription = null, + ): self + { + return new self(false, 400, $error ?? 'invalid_request', $errorDescription, null, []); + } + + public function isAllowed(): bool + { + return $this->allowed; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getError(): ?string + { + return $this->error; + } + + public function getErrorDescription(): ?string + { + return $this->errorDescription; + } + + /** + * @return list|null + */ + public function getScopes(): ?array + { + return $this->scopes; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php b/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php new file mode 100644 index 00000000..6c315e72 --- /dev/null +++ b/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php @@ -0,0 +1,35 @@ + + */ +interface AuthorizationTokenValidatorInterface +{ + /** + * Validates an access token extracted from the Authorization header. + * + * @param ServerRequestInterface $request The incoming HTTP request + * @param string $accessToken The bearer token (without "Bearer " prefix) + * + * @return AuthorizationResult The result of the validation + */ + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult; +} diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php new file mode 100644 index 00000000..518db598 --- /dev/null +++ b/src/Server/Transport/Middleware/JwtTokenValidator.php @@ -0,0 +1,407 @@ + + */ +class JwtTokenValidator implements AuthorizationTokenValidatorInterface +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private ?OidcDiscovery $discovery = null; + + private const CACHE_KEY_PREFIX = 'mcp_jwt_jwks_'; + + /** + * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") For Microsoft Entra ID, you may need to provide both v1.0 and v2.0 issuers + * @param string|list $audience Expected audience(s) for the token + * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache PSR-16 cache for JWKS (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) + * @param string $scopeClaim Claim name for scopes (default: "scope") + */ + public function __construct( + private readonly string|array $issuer, + private readonly string|array $audience, + private readonly ?string $jwksUri = null, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + private readonly array $algorithms = ['RS256', 'RS384', 'RS512'], + private readonly string $scopeClaim = 'scope', + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + } + + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + // Decode header to see key ID + $parts = explode('.', $accessToken); + $header = null; + if (count($parts) >= 2) { + $header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true); + } + + // Microsoft Graph tokens have 'nonce' in header and cannot be verified externally + // These are opaque tokens meant only for Microsoft Graph API + if (isset($header['nonce'])) { + return $this->validateGraphToken($accessToken, $parts); + } + + try { + $keys = $this->getJwks(); + $decoded = JWT::decode($accessToken, $keys); + /** @var array $claims */ + $claims = (array)$decoded; + + // Validate issuer + if (!$this->validateIssuer($claims)) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token issuer mismatch.' + ); + } + + // Validate audience + if (!$this->validateAudience($claims)) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token audience mismatch.' + ); + } + + // Extract scopes + $scopes = $this->extractScopes($claims); + + // Build attributes to attach to request + $attributes = [ + 'oauth.claims' => $claims, + 'oauth.scopes' => $scopes, + ]; + + // Add common claims as individual attributes + if (isset($claims['sub'])) { + $attributes['oauth.subject'] = $claims['sub']; + } + + if (isset($claims['client_id'])) { + $attributes['oauth.client_id'] = $claims['client_id']; + } + + // Add azp (authorized party) for OIDC tokens + if (isset($claims['azp'])) { + $attributes['oauth.authorized_party'] = $claims['azp']; + } + + return AuthorizationResult::allow($attributes); + } catch (\Firebase\JWT\ExpiredException $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token has expired.' + ); + } catch (\Firebase\JWT\SignatureInvalidException $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token signature verification failed.' + ); + } catch (\Firebase\JWT\BeforeValidException $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token is not yet valid.' + ); + } catch (\UnexpectedValueException|\DomainException $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token validation failed: ' . $e->getMessage() + ); + } catch (\Throwable $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token validation error.' + ); + } + } + + /** + * Validates Microsoft Graph tokens that cannot be signature-verified externally. + * + * Microsoft Graph access tokens contain a 'nonce' in the header and use a special + * format where the signature cannot be verified by third parties. These tokens are + * meant only for Microsoft Graph API consumption. + * + * This method performs claim-based validation without signature verification. + * + * @param string $accessToken The JWT access token + * @param array $parts Token parts (header, payload, signature) + */ + private function validateGraphToken(string $accessToken, array $parts): AuthorizationResult + { + if (count($parts) < 2) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); + } + + try { + $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); + if (null === $payload) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.'); + } + + // Validate expiration + if (isset($payload['exp']) && $payload['exp'] < time()) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } + + // Validate not before + if (isset($payload['nbf']) && $payload['nbf'] > time() + 60) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); + } + + // For Graph tokens, we accept them if they came from Microsoft + // The issuer should be Microsoft's STS + $issuer = $payload['iss'] ?? ''; + if (!str_contains($issuer, 'sts.windows.net') && !str_contains($issuer, 'login.microsoftonline.com')) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.'); + } + + // Extract scopes + $scopes = $this->extractScopes($payload); + + // Build attributes + $attributes = [ + 'oauth.claims' => $payload, + 'oauth.scopes' => $scopes, + 'oauth.graph_token' => true, // Mark as Graph token + ]; + + if (isset($payload['sub'])) { + $attributes['oauth.subject'] = $payload['sub']; + } + + if (isset($payload['oid'])) { + $attributes['oauth.object_id'] = $payload['oid']; + } + + if (isset($payload['name'])) { + $attributes['oauth.name'] = $payload['name']; + } + + return AuthorizationResult::allow($attributes); + } catch (\Throwable $e) { + return AuthorizationResult::unauthorized('invalid_token', 'Graph token validation failed.'); + } + } + + /** + * Validates a token has the required scopes. + * + * Use this after validation to check specific scope requirements. + * + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation + * + * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise + */ + public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult + { + if (!$result->isAllowed()) { + return $result; + } + + $tokenScopes = $result->getAttributes()['oauth.scopes'] ?? []; + + if (!\is_array($tokenScopes)) { + $tokenScopes = []; + } + + foreach ($requiredScopes as $required) { + if (!\in_array($required, $tokenScopes, true)) { + return AuthorizationResult::forbidden( + 'insufficient_scope', + sprintf('Required scope: %s', $required), + $requiredScopes + ); + } + } + + return $result; + } + + /** + * @return array + */ + private function getJwks(): array + { + $jwksUri = $this->resolveJwksUri(); + $cacheKey = self::CACHE_KEY_PREFIX . hash('sha256', $jwksUri); + + $jwksData = null; + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if (\is_array($cached)) { + /** @var array $cached */ + $jwksData = $cached; + } + } + + if (null === $jwksData) { + $jwksData = $this->fetchJwks($jwksUri); + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $jwksData, $this->cacheTtl); + } + } + + /** @var array */ + return JWK::parseKeySet($jwksData, $this->algorithms[0]); + } + + private function resolveJwksUri(): string + { + if (null !== $this->jwksUri) { + return $this->jwksUri; + } + + // Auto-discover from first issuer + if (null === $this->discovery) { + $this->discovery = new OidcDiscovery( + $this->httpClient, + $this->requestFactory, + $this->cache, + $this->cacheTtl + ); + } + + $issuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; + + return $this->discovery->getJwksUri($issuers[0]); + } + + /** + * @param array $claims + */ + private function validateIssuer(array $claims): bool + { + if (!isset($claims['iss'])) { + return false; + } + + $tokenIssuer = $claims['iss']; + $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; + + return \in_array($tokenIssuer, $expectedIssuers, true); + } + + /** + * @return array + */ + private function fetchJwks(string $jwksUri): array + { + $request = $this->requestFactory->createRequest('GET', $jwksUri) + ->withHeader('Accept', 'application/json'); + + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() >= 400) { + throw new \RuntimeException(sprintf( + 'Failed to fetch JWKS from %s: HTTP %d', + $jwksUri, + $response->getStatusCode() + )); + } + + $body = (string)$response->getBody(); + + try { + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + } + + if (!\is_array($data) || !isset($data['keys'])) { + throw new \RuntimeException('Invalid JWKS format: missing "keys" array.'); + } + + /** @var array $data */ + return $data; + } + + /** + * @param array $claims + */ + private function validateAudience(array $claims): bool + { + if (!isset($claims['aud'])) { + return false; + } + + $tokenAudiences = \is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; + $expectedAudiences = \is_array($this->audience) ? $this->audience : [$this->audience]; + + foreach ($expectedAudiences as $expected) { + if (\in_array($expected, $tokenAudiences, true)) { + return true; + } + } + + return false; + } + + /** + * @param array $claims + * + * @return list + */ + private function extractScopes(array $claims): array + { + if (!isset($claims[$this->scopeClaim])) { + return []; + } + + $scopeValue = $claims[$this->scopeClaim]; + + if (\is_array($scopeValue)) { + return array_values(array_filter($scopeValue, 'is_string')); + } + + if (\is_string($scopeValue)) { + return array_values(array_filter(explode(' ', $scopeValue))); + } + + return []; + } +} diff --git a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php new file mode 100644 index 00000000..a0a5e5f1 --- /dev/null +++ b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php @@ -0,0 +1,234 @@ + + */ +final class OAuthProxyMiddleware implements MiddlewareInterface +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + private ?array $upstreamMetadata = null; + + /** + * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider + * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) + * @param string|null $clientSecret Optional client secret for confidential clients + */ + public function __construct( + private readonly string $upstreamIssuer, + private readonly string $localBaseUrl, + private readonly ?string $clientSecret = null, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $path = $request->getUri()->getPath(); + + // Serve local authorization server metadata + if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) { + return $this->createAuthServerMetadataResponse(); + } + + // Handle authorization endpoint - redirect to upstream + if ('GET' === $request->getMethod() && '/authorize' === $path) { + return $this->handleAuthorize($request); + } + + // Handle token endpoint - proxy to upstream + if ('POST' === $request->getMethod() && '/token' === $path) { + return $this->handleToken($request); + } + + // Pass through to next handler + return $handler->handle($request); + } + + private function handleAuthorize(ServerRequestInterface $request): ResponseInterface + { + $upstreamMetadata = $this->getUpstreamMetadata(); + $authorizationEndpoint = $upstreamMetadata['authorization_endpoint'] ?? null; + + if (null === $authorizationEndpoint) { + return $this->createErrorResponse(500, 'Upstream authorization endpoint not found'); + } + + // Get the raw query string to preserve exact encoding (important for PKCE) + $rawQueryString = $request->getUri()->getQuery(); + + // Build upstream URL preserving exact query string + $upstreamUrl = $authorizationEndpoint . '?' . $rawQueryString; + + // Redirect to upstream authorization server + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', $upstreamUrl) + ->withHeader('Cache-Control', 'no-store'); + } + + private function handleToken(ServerRequestInterface $request): ResponseInterface + { + $upstreamMetadata = $this->getUpstreamMetadata(); + $tokenEndpoint = $upstreamMetadata['token_endpoint'] ?? null; + + if (null === $tokenEndpoint) { + return $this->createErrorResponse(500, 'Upstream token endpoint not found'); + } + + // Get the request body and parse it + $body = (string)$request->getBody(); + parse_str($body, $params); + + // Inject client_secret if configured and not already present + if (null !== $this->clientSecret && !isset($params['client_secret'])) { + $params['client_secret'] = $this->clientSecret; + } + + // Rebuild body with potentially added client_secret + $body = http_build_query($params); + + // Create upstream request + $upstreamRequest = $this->requestFactory + ->createRequest('POST', $tokenEndpoint) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($this->streamFactory->createStream($body)); + + // Forward any Authorization header (for client credentials) + if ($request->hasHeader('Authorization')) { + $upstreamRequest = $upstreamRequest->withHeader('Authorization', $request->getHeaderLine('Authorization')); + } + + try { + $upstreamResponse = $this->httpClient->sendRequest($upstreamRequest); + $responseBody = (string)$upstreamResponse->getBody(); + + // Return upstream response as-is + return $this->responseFactory + ->createResponse($upstreamResponse->getStatusCode()) + ->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type')) + ->withHeader('Cache-Control', 'no-store') + ->withBody($this->streamFactory->createStream($responseBody)); + } catch (\Throwable $e) { + return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: ' . $e->getMessage()); + } + } + + private function createAuthServerMetadataResponse(): ResponseInterface + { + $upstreamMetadata = $this->getUpstreamMetadata(); + + // Create local metadata that points to our proxy endpoints + $localMetadata = [ + 'issuer' => $this->upstreamIssuer, + 'authorization_endpoint' => rtrim($this->localBaseUrl, '/') . '/authorize', + 'token_endpoint' => rtrim($this->localBaseUrl, '/') . '/token', + 'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'], + 'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], + ]; + + // Copy additional useful fields from upstream + $copyFields = [ + 'scopes_supported', + 'token_endpoint_auth_methods_supported', + 'jwks_uri', + ]; + + foreach ($copyFields as $field) { + if (isset($upstreamMetadata[$field])) { + $localMetadata[$field] = $upstreamMetadata[$field]; + } + } + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'max-age=3600') + ->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_UNESCAPED_SLASHES))); + } + + private function getUpstreamMetadata(): array + { + if (null !== $this->upstreamMetadata) { + return $this->upstreamMetadata; + } + + // Try OpenID Connect discovery first + $discoveryUrls = [ + rtrim($this->upstreamIssuer, '/') . '/.well-known/openid-configuration', + rtrim($this->upstreamIssuer, '/') . '/.well-known/oauth-authorization-server', + ]; + + foreach ($discoveryUrls as $url) { + try { + $request = $this->requestFactory->createRequest('GET', $url); + $response = $this->httpClient->sendRequest($request); + + if (200 === $response->getStatusCode()) { + $this->upstreamMetadata = json_decode((string)$response->getBody(), true) ?? []; + + return $this->upstreamMetadata; + } + } catch (\Throwable) { + // Try next URL + } + } + + $this->upstreamMetadata = []; + + return $this->upstreamMetadata; + } + + private function createErrorResponse(int $status, string $message): ResponseInterface + { + $body = json_encode(['error' => 'server_error', 'error_description' => $message]); + + return $this->responseFactory + ->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($body)); + } +} diff --git a/src/Server/Transport/Middleware/OidcDiscovery.php b/src/Server/Transport/Middleware/OidcDiscovery.php new file mode 100644 index 00000000..5124c83c --- /dev/null +++ b/src/Server/Transport/Middleware/OidcDiscovery.php @@ -0,0 +1,291 @@ + + */ +class OidcDiscovery +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + + private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; + + /** + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + */ + public function __construct( + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + } + + /** + * Discovers authorization server metadata from the issuer URL. + * + * Tries endpoints in priority order per RFC 8414 and OpenID Connect Discovery: + * 1. OAuth 2.0 path insertion: /.well-known/oauth-authorization-server/{path} + * 2. OIDC path insertion: /.well-known/openid-configuration/{path} + * 3. OIDC path appending: {path}/.well-known/openid-configuration + * + * @param string $issuer The issuer URL (e.g., "https://auth.example.com/realms/mcp") + * + * @return array The authorization server metadata + * + * @throws \RuntimeException If discovery fails + */ + public function discover(string $issuer): array + { + $cacheKey = self::CACHE_KEY_PREFIX . hash('sha256', $issuer); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if (\is_array($cached)) { + return $cached; + } + } + + $metadata = $this->fetchMetadata($issuer); + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $metadata, $this->cacheTtl); + } + + return $metadata; + } + + /** + * Gets the JWKS URI from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The JWKS URI + * + * @throws \RuntimeException If JWKS URI is not found in metadata + */ + public function getJwksUri(string $issuer): string + { + $metadata = $this->discover($issuer); + + if (!isset($metadata['jwks_uri']) || !\is_string($metadata['jwks_uri'])) { + throw new \RuntimeException('Authorization server metadata does not contain jwks_uri.'); + } + + return $metadata['jwks_uri']; + } + + /** + * Fetches JWKS (JSON Web Key Set) from the authorization server. + * + * @param string $issuer The issuer URL + * + * @return array The JWKS + * + * @throws \RuntimeException If fetching fails + */ + public function fetchJwks(string $issuer): array + { + $jwksUri = $this->getJwksUri($issuer); + + $cacheKey = self::CACHE_KEY_PREFIX . 'jwks_' . hash('sha256', $jwksUri); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if (\is_array($cached)) { + return $cached; + } + } + + $jwks = $this->fetchJson($jwksUri); + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $jwks, $this->cacheTtl); + } + + return $jwks; + } + + /** + * Checks if the authorization server supports PKCE. + * + * @param string $issuer The issuer URL + * + * @return bool True if PKCE is supported (code_challenge_methods_supported includes S256) + */ + public function supportsPkce(string $issuer): bool + { + $metadata = $this->discover($issuer); + + if (!isset($metadata['code_challenge_methods_supported']) || !\is_array($metadata['code_challenge_methods_supported'])) { + return false; + } + + return \in_array('S256', $metadata['code_challenge_methods_supported'], true); + } + + /** + * Gets the token endpoint from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The token endpoint URL + * + * @throws \RuntimeException If token endpoint is not found + */ + public function getTokenEndpoint(string $issuer): string + { + $metadata = $this->discover($issuer); + + if (!isset($metadata['token_endpoint']) || !\is_string($metadata['token_endpoint'])) { + throw new \RuntimeException('Authorization server metadata does not contain token_endpoint.'); + } + + return $metadata['token_endpoint']; + } + + /** + * Gets the authorization endpoint from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The authorization endpoint URL + * + * @throws \RuntimeException If authorization endpoint is not found + */ + public function getAuthorizationEndpoint(string $issuer): string + { + $metadata = $this->discover($issuer); + + if (!isset($metadata['authorization_endpoint']) || !\is_string($metadata['authorization_endpoint'])) { + throw new \RuntimeException('Authorization server metadata does not contain authorization_endpoint.'); + } + + return $metadata['authorization_endpoint']; + } + + /** + * @return array + */ + private function fetchMetadata(string $issuer): array + { + $issuer = rtrim($issuer, '/'); + $parsed = parse_url($issuer); + + if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { + throw new \RuntimeException(sprintf('Invalid issuer URL: %s', $issuer)); + } + + $scheme = $parsed['scheme']; + $host = $parsed['host']; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + $path = $parsed['path'] ?? ''; + + $baseUrl = $scheme . '://' . $host . $port; + + // Build discovery URLs in priority order per RFC 8414 Section 3.1 + $discoveryUrls = []; + + if ('' !== $path && '/' !== $path) { + // For issuer URLs with path components + // 1. OAuth 2.0 path insertion + $discoveryUrls[] = $baseUrl . '/.well-known/oauth-authorization-server' . $path; + // 2. OIDC path insertion + $discoveryUrls[] = $baseUrl . '/.well-known/openid-configuration' . $path; + // 3. OIDC path appending + $discoveryUrls[] = $issuer . '/.well-known/openid-configuration'; + } else { + // For issuer URLs without path components + $discoveryUrls[] = $baseUrl . '/.well-known/oauth-authorization-server'; + $discoveryUrls[] = $baseUrl . '/.well-known/openid-configuration'; + } + + $lastException = null; + + foreach ($discoveryUrls as $url) { + try { + $metadata = $this->fetchJson($url); + + // Validate issuer claim matches + if (isset($metadata['issuer']) && $metadata['issuer'] !== $issuer) { + continue; + } + + return $metadata; + } catch (\RuntimeException $e) { + $lastException = $e; + continue; + } + } + + throw new \RuntimeException( + sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), + 0, + $lastException + ); + } + + /** + * @return array + */ + private function fetchJson(string $url): array + { + $request = $this->requestFactory->createRequest('GET', $url) + ->withHeader('Accept', 'application/json'); + + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() >= 400) { + throw new \RuntimeException(sprintf( + 'HTTP request to %s failed with status %d', + $url, + $response->getStatusCode() + )); + } + + $body = (string)$response->getBody(); + + try { + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); + } + + if (!\is_array($data)) { + throw new \RuntimeException(sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); + } + + return $data; + } +} diff --git a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php new file mode 100644 index 00000000..067efa05 --- /dev/null +++ b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php @@ -0,0 +1,88 @@ + + */ +class ProtectedResourceMetadata +{ + /** + * @param list $authorizationServers URLs of authorization servers that can issue tokens for this resource + * @param list|null $scopesSupported OAuth scopes supported by this resource + * @param string|null $resource The resource identifier (typically the resource's URL) + * @param array $extra Additional metadata fields + */ + public function __construct( + private readonly array $authorizationServers, + private readonly ?array $scopesSupported = null, + private readonly ?string $resource = null, + private readonly array $extra = [], + ) { + if (empty($authorizationServers)) { + throw new \InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); + } + } + + /** + * @return list + */ + public function getAuthorizationServers(): array + { + return $this->authorizationServers; + } + + /** + * @return list|null + */ + public function getScopesSupported(): ?array + { + return $this->scopesSupported; + } + + public function getResource(): ?string + { + return $this->resource; + } + + /** + * @return array + */ + public function toArray(): array + { + $data = [ + 'authorization_servers' => array_values($this->authorizationServers), + ]; + + if (null !== $this->scopesSupported) { + $data['scopes_supported'] = array_values($this->scopesSupported); + } + + if (null !== $this->resource) { + $data['resource'] = $this->resource; + } + + return array_merge($this->extra, $data); + } + + public function toJson(): string + { + return json_encode($this->toArray(), \JSON_THROW_ON_ERROR); + } +} From 79a9916a799ff07f6e0899d5d9a5dd6c27921af0 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Wed, 28 Jan 2026 16:31:42 +0100 Subject: [PATCH 03/18] OAuth Implementation based on middleware --- composer.json | 3 +- examples/server/oauth-keycloak/server.php | 28 +- examples/server/oauth-microsoft/server.php | 18 +- .../Middleware/AuthorizationMiddleware.php | 24 +- .../Middleware/AuthorizationResult.php | 2 +- .../Middleware/JwtTokenValidator.php | 50 +- .../AuthorizationMiddlewareTest.php | 248 ++++++ .../Middleware/JwtTokenValidatorTest.php | 716 ++++++++++++++++++ 8 files changed, 1002 insertions(+), 87 deletions(-) create mode 100644 tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php diff --git a/composer.json b/composer.json index 49919041..665ec425 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,8 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", - "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" + "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "ext-openssl": "*" }, "autoload": { "psr-4": { diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php index f3f8b05f..c8a7e667 100644 --- a/examples/server/oauth-keycloak/server.php +++ b/examples/server/oauth-keycloak/server.php @@ -11,7 +11,7 @@ declare(strict_types=1); -require_once dirname(__DIR__, 3).'/vendor/autoload.php'; +require_once dirname(__DIR__).'/bootstrap.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; @@ -21,30 +21,20 @@ use Mcp\Server\Transport\Middleware\JwtTokenValidator; use Mcp\Server\Transport\Middleware\ProtectedResourceMetadata; use Mcp\Server\Transport\StreamableHttpTransport; -use Psr\Log\AbstractLogger; -// Configuration from environment +// Configuration // External URL is what clients use and what appears in tokens -$keycloakExternalUrl = getenv('KEYCLOAK_EXTERNAL_URL') ?: 'http://localhost:8180'; +$keycloakExternalUrl = 'http://localhost:8180'; // Internal URL is how this server reaches Keycloak (Docker network) -$keycloakInternalUrl = getenv('KEYCLOAK_INTERNAL_URL') ?: 'http://keycloak:8080'; -$keycloakRealm = getenv('KEYCLOAK_REALM') ?: 'mcp'; -$mcpAudience = getenv('MCP_AUDIENCE') ?: 'mcp-server'; +$keycloakInternalUrl = 'http://keycloak:8080'; +$keycloakRealm = 'mcp'; +$mcpAudience = 'mcp-server'; // Issuer is what appears in the token (external URL) $issuer = rtrim($keycloakExternalUrl, '/').'/realms/'.$keycloakRealm; // JWKS URI uses internal URL to reach Keycloak within Docker network $jwksUri = rtrim($keycloakInternalUrl, '/').'/realms/'.$keycloakRealm.'/protocol/openid-connect/certs'; -// Create logger -$logger = new class extends AbstractLogger { - public function log($level, \Stringable|string $message, array $context = []): void - { - $logMessage = sprintf("[%s] %s\n", strtoupper($level), $message); - error_log($logMessage); - } -}; - // Create PSR-17 factory $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); @@ -77,15 +67,15 @@ public function log($level, \Stringable|string $message, array $context = []): v // Build MCP server $server = Server::builder() ->setServerInfo('OAuth Keycloak Example', '1.0.0') - ->setLogger($logger) - ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__) ->build(); // Create transport with authorization middleware $transport = new StreamableHttpTransport( $request, - logger: $logger, + logger: logger(), middlewares: [$authMiddleware], ); diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index da5c5cc7..97ee1140 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -11,7 +11,7 @@ declare(strict_types=1); -require_once dirname(__DIR__, 3).'/vendor/autoload.php'; +require_once dirname(__DIR__).'/bootstrap.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; @@ -23,7 +23,6 @@ use Mcp\Server\Transport\Middleware\ProtectedResourceMetadata; use Mcp\Server\Transport\StreamableHttpTransport; use Psr\Log\AbstractLogger; -use Psr\Log\LoggerInterface; // Configuration from environment $tenantId = getenv('AZURE_TENANT_ID') ?: throw new RuntimeException('AZURE_TENANT_ID environment variable is required'); @@ -36,15 +35,6 @@ $issuerV1 = "https://sts.windows.net/{$tenantId}/"; $issuers = [$issuerV2, $issuerV1]; -// Create logger -$logger = new class extends AbstractLogger { - public function log($level, \Stringable|string $message, array $context = []): void - { - $logMessage = sprintf("[%s] %s\n", strtoupper($level), $message); - error_log($logMessage); - } -}; - // Create PSR-17 factory $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); @@ -90,8 +80,8 @@ public function log($level, \Stringable|string $message, array $context = []): v // Build MCP server $server = Server::builder() ->setServerInfo('OAuth Microsoft Example', '1.0.0') - ->setLogger($logger) - ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__) ->build(); @@ -99,7 +89,7 @@ public function log($level, \Stringable|string $message, array $context = []): v // Middlewares are reversed internally, so put OAuth proxy FIRST to execute FIRST $transport = new StreamableHttpTransport( $request, - logger: $logger, + logger: logger(), middlewares: [$oauthProxyMiddleware, $authMiddleware], ); diff --git a/src/Server/Transport/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Middleware/AuthorizationMiddleware.php index 6bf6319b..dd24c30a 100644 --- a/src/Server/Transport/Middleware/AuthorizationMiddleware.php +++ b/src/Server/Transport/Middleware/AuthorizationMiddleware.php @@ -70,18 +70,15 @@ public function __construct( public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - // Serve metadata at well-known paths if ($this->isMetadataRequest($request)) { return $this->createMetadataResponse(); } - // Extract Authorization header $authorization = $request->getHeaderLine('Authorization'); if ('' === $authorization) { return $this->buildErrorResponse($request, AuthorizationResult::unauthorized()); } - // Parse Bearer token $accessToken = $this->parseBearerToken($authorization); if (null === $accessToken) { return $this->buildErrorResponse( @@ -90,7 +87,6 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface ); } - // Validate the token $result = $this->validator->validate($request, $accessToken); if ($result->isAllowed()) { return $handler->handle($this->applyAttributes($request, $result->getAttributes())); @@ -101,33 +97,19 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface private function createMetadataResponse(): ResponseInterface { - $payload = $this->metadata->toJson(); - return $this->responseFactory ->createResponse(200) ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($payload)); + ->withBody($this->streamFactory->createStream($this->metadata->toJson())); } private function isMetadataRequest(ServerRequestInterface $request): bool { - if (empty($this->metadataPaths)) { - return false; - } - - if ('GET' !== $request->getMethod()) { + if (empty($this->metadataPaths) || 'GET' !== $request->getMethod()) { return false; } - $path = $request->getUri()->getPath(); - - foreach ($this->metadataPaths as $metadataPath) { - if ($path === $metadataPath) { - return true; - } - } - - return false; + return in_array($request->getUri()->getPath(), $this->metadataPaths, true); } private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface diff --git a/src/Server/Transport/Middleware/AuthorizationResult.php b/src/Server/Transport/Middleware/AuthorizationResult.php index 3750b17d..f3955a54 100644 --- a/src/Server/Transport/Middleware/AuthorizationResult.php +++ b/src/Server/Transport/Middleware/AuthorizationResult.php @@ -22,7 +22,7 @@ * * @author Volodymyr Panivko */ -class AuthorizationResult +final class AuthorizationResult { /** * @param list|null $scopes Scopes to include in WWW-Authenticate challenge diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php index 518db598..b557dfa8 100644 --- a/src/Server/Transport/Middleware/JwtTokenValidator.php +++ b/src/Server/Transport/Middleware/JwtTokenValidator.php @@ -11,10 +11,14 @@ namespace Mcp\Server\Transport\Middleware; +use Firebase\JWT\BeforeValidException; +use Firebase\JWT\ExpiredException; use Firebase\JWT\JWK; use Firebase\JWT\JWT; +use Firebase\JWT\SignatureInvalidException; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; +use Mcp\Exception\RuntimeException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ServerRequestInterface; @@ -128,31 +132,16 @@ public function validate(ServerRequestInterface $request, string $accessToken): } return AuthorizationResult::allow($attributes); - } catch (\Firebase\JWT\ExpiredException $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token has expired.' - ); - } catch (\Firebase\JWT\SignatureInvalidException $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token signature verification failed.' - ); - } catch (\Firebase\JWT\BeforeValidException $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token is not yet valid.' - ); + } catch (ExpiredException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } catch (SignatureInvalidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.'); + } catch (BeforeValidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); } catch (\UnexpectedValueException|\DomainException $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token validation failed: ' . $e->getMessage() - ); - } catch (\Throwable $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token validation error.' - ); + return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage()); + } catch (\Throwable) { + return AuthorizationResult::unauthorized('invalid_token', 'Token validation error.'); } } @@ -320,10 +309,9 @@ private function validateIssuer(array $claims): bool return false; } - $tokenIssuer = $claims['iss']; $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; - return \in_array($tokenIssuer, $expectedIssuers, true); + return \in_array($claims['iss'], $expectedIssuers, true); } /** @@ -336,24 +324,24 @@ private function fetchJwks(string $jwksUri): array $response = $this->httpClient->sendRequest($request); - if ($response->getStatusCode() >= 400) { - throw new \RuntimeException(sprintf( + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(sprintf( 'Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode() )); } - $body = (string)$response->getBody(); + $body = (string) $response->getBody(); try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new \RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + throw new RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); } if (!\is_array($data) || !isset($data['keys'])) { - throw new \RuntimeException('Invalid JWKS format: missing "keys" array.'); + throw new RuntimeException('Invalid JWKS format: missing "keys" array.'); } /** @var array $data */ diff --git a/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php b/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php new file mode 100644 index 00000000..aaa5aee8 --- /dev/null +++ b/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php @@ -0,0 +1,248 @@ +createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(401, $response->getStatusCode()); + $header = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('Bearer', $header); + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + $header, + ); + $this->assertStringContainsString('scope="mcp:read"', $header); + } + + #[TestDox('malformed Authorization header returns 400 with invalid_request')] + public function testMalformedAuthorizationReturns400(): void + { + $factory = new Psr17Factory(); + $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(); + } + }; + + $middleware = new AuthorizationMiddleware( + $metadata, + $validator, + $factory, + $factory, + ['/.well-known/oauth-protected-resource'], + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Basic abc'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('error="invalid_request"', $response->getHeaderLine('WWW-Authenticate')); + } + + #[TestDox('insufficient scopes return 403 with scope challenge')] + public function testInsufficientScopeReturns403(): void + { + $factory = new Psr17Factory(); + $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + return AuthorizationResult::forbidden('insufficient_scope', 'Need more scopes.', ['mcp:write']); + } + }; + + $middleware = new AuthorizationMiddleware( + $metadata, + $validator, + $factory, + $factory, + ['/.well-known/oauth-protected-resource'], + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Bearer token'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(403, $response->getStatusCode()); + $header = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('error="insufficient_scope"', $header); + $this->assertStringContainsString('scope="mcp:write"', $header); + } + + #[TestDox('metadata endpoint returns protected resource metadata JSON')] + public function testMetadataEndpointReturnsJson(): void + { + $factory = new Psr17Factory(); + $metadata = new ProtectedResourceMetadata( + ['https://auth.example.com'], + ['mcp:read', 'mcp:write'], + ); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(); + } + }; + + $middleware = new AuthorizationMiddleware( + $metadata, + $validator, + $factory, + $factory, + ['/.well-known/oauth-protected-resource'], + ); + + $request = $factory->createServerRequest( + 'GET', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode((string) $response->getBody(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); + } + + #[TestDox('authorized requests reach the handler with attributes applied')] + public function testAllowedRequestPassesAttributes(): void + { + $factory = new Psr17Factory(); + $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(['subject' => 'user-1']); + } + }; + + $middleware = new AuthorizationMiddleware( + $metadata, + $validator, + $factory, + $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Bearer token'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200) + ->withHeader('X-Subject', (string) $request->getAttribute('subject')); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('user-1', $response->getHeaderLine('X-Subject')); + } +} diff --git a/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php new file mode 100644 index 00000000..95cfbcc2 --- /dev/null +++ b/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php @@ -0,0 +1,716 @@ +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))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: $jwksUri, + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'client_id' => 'client-abc', + 'azp' => 'client-abc', + 'scope' => 'mcp:read mcp:write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $result = $validator->validate($request, $token); + + $this->assertTrue($result->isAllowed()); + $attributes = $result->getAttributes(); + + $this->assertArrayHasKey('oauth.claims', $attributes); + $this->assertArrayHasKey('oauth.scopes', $attributes); + $this->assertSame(['mcp:read', 'mcp:write'], $attributes['oauth.scopes']); + $this->assertSame('user-123', $attributes['oauth.subject']); + $this->assertSame('client-abc', $attributes['oauth.client_id']); + $this->assertSame('client-abc', $attributes['oauth.authorized_party']); + } + + #[TestDox('issuer mismatch yields unauthorized result')] + public function testIssuerMismatchIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $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))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: $jwksUri, + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://other-issuer.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $result = $validator->validate($request, $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token issuer mismatch.', $result->getErrorDescription()); + } + + #[TestDox('audience mismatch yields unauthorized result')] + public function testAudienceMismatchIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $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))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: ['mcp-api'], + jwksUri: $jwksUri, + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'different-aud', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $result = $validator->validate($request, $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token audience mismatch.', $result->getErrorDescription()); + } + + #[TestDox('Graph token (nonce header) is validated by claims without signature verification')] + public function testGraphTokenWithNonceHeaderIsAllowed(): void + { + $factory = new Psr17Factory(); + + // Build a token with a header containing "nonce" to trigger validateGraphToken(). + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + $payload = $this->b64urlEncode(json_encode([ + 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read files.write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], \JSON_THROW_ON_ERROR)); + + $token = $header . '.' . $payload . '.'; + + $validator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksUri: 'https://unused.example.com/jwks', + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory, + scopeClaim: 'scp', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $result = $validator->validate($request, $token); + + $this->assertTrue($result->isAllowed()); + $attributes = $result->getAttributes(); + + $this->assertTrue($attributes['oauth.graph_token']); + $this->assertSame(['files.read', 'files.write'], $attributes['oauth.scopes']); + $this->assertSame('user-graph', $attributes['oauth.subject']); + } + + #[TestDox('expired token yields unauthorized invalid_token with expired message')] + public function testExpiredTokenIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 7200, + 'exp' => time() - 10, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token has expired.', $result->getErrorDescription()); + } + + #[TestDox('token with future nbf yields unauthorized invalid_token with not-yet-valid message')] + public function testBeforeValidTokenIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time(), + 'nbf' => time() + 3600, + 'exp' => time() + 7200, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token is not yet valid.', $result->getErrorDescription()); + } + + #[TestDox('signature verification failure yields unauthorized invalid_token with signature message')] + public function testSignatureInvalidIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + // Create a mismatched JWK with the same kid so the key lookup succeeds but signature verification fails. + [, $mismatchedJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + $mismatchedJwk['kid'] = $publicJwk['kid']; + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$mismatchedJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token signature verification failed.', $result->getErrorDescription()); + } + + #[TestDox('JWKS HTTP error results in unauthorized token validation error')] + public function testJwksHttpErrorResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), + requestFactory: $factory, + ); + + // Any token without the Graph nonce will attempt JWKS and fail. + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('Invalid JWKS JSON results in unauthorized token validation error')] + public function testInvalidJwksJsonResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{not-json')), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('JWKS without keys array results in unauthorized token validation error')] + public function testJwksMissingKeysResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['nope' => []], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('requireScopes returns forbidden when any required scope is missing')] + public function testRequireScopesForbiddenWhenMissing(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $this->assertTrue($result->isAllowed()); + + $scoped = $validator->requireScopes($result, ['mcp:read', 'mcp:write']); + $this->assertFalse($scoped->isAllowed()); + $this->assertSame(403, $scoped->getStatusCode()); + $this->assertSame('insufficient_scope', $scoped->getError()); + $this->assertSame(['mcp:read', 'mcp:write'], $scoped->getScopes()); + } + + #[TestDox('requireScopes passes through when all required scopes are present')] + public function testRequireScopesPassesWhenPresent(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => ['mcp:read', 'mcp:write'], + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $this->assertTrue($result->isAllowed()); + + $scoped = $validator->requireScopes($result, ['mcp:read']); + $this->assertTrue($scoped->isAllowed()); + } + + #[TestDox('Graph token invalid format is unauthorized')] + public function testGraphTokenInvalidFormatIsUnauthorized(): void + { + $factory = new Psr17Factory(); + + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + // Trigger the Graph token path (nonce in header) with an empty payload segment. + // This makes validateGraphToken() run and fail decoding the payload. + $token = $header . '..'; + + $validator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksUri: 'https://unused.example.com/jwks', + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory, + scopeClaim: 'scp', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token payload.', $result->getErrorDescription()); + } + + #[TestDox('Graph token invalid issuer is unauthorized with graph issuer message')] + public function testGraphTokenInvalidIssuerIsUnauthorized(): void + { + $factory = new Psr17Factory(); + + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + $payload = $this->b64urlEncode(json_encode([ + 'iss' => 'https://evil.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], \JSON_THROW_ON_ERROR)); + + $token = $header . '.' . $payload . '.'; + + $validator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksUri: 'https://unused.example.com/jwks', + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory, + scopeClaim: 'scp', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription()); + } + + #[TestDox('extractScopes returns empty array when scope claim is missing or invalid type')] + public function testExtractScopesEdgeCases(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksResponse = $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))); + + $httpClient = $this->createHttpClientMock([$jwksResponse]); + + // missing scope + $validatorMissing = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $tokenMissing = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $resultMissing = $validatorMissing->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $tokenMissing); + $this->assertTrue($resultMissing->isAllowed()); + $this->assertSame([], $resultMissing->getAttributes()['oauth.scopes']); + + // invalid scope type + $httpClient2 = $this->createHttpClientMock([$jwksResponse]); + + $validatorInvalid = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient2, + requestFactory: $factory, + ); + + $tokenInvalid = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 123, + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $resultInvalid = $validatorInvalid->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $tokenInvalid); + $this->assertTrue($resultInvalid->isAllowed()); + $this->assertSame([], $resultInvalid->getAttributes()['oauth.scopes']); + } + + private function unsignedJwt(array $claims): string + { + $header = $this->b64urlEncode(json_encode(['alg' => 'none', 'typ' => 'JWT'], \JSON_THROW_ON_ERROR)); + $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); + + return $header . '.' . $payload . '.'; + } + + /** + * @return array{0: string, 1: array} + */ + private function generateRsaKeypairAsJwk(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + + if (false === $key) { + self::fail('Failed to generate RSA keypair via OpenSSL.'); + } + + $privateKeyPem = ''; + if (!openssl_pkey_export($key, $privateKeyPem)) { + self::fail('Failed to export RSA private key.'); + } + + $details = openssl_pkey_get_details($key); + if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { + self::fail('Failed to read RSA key details.'); + } + + $n = $this->b64urlEncode($details['rsa']['n']); + $e = $this->b64urlEncode($details['rsa']['e']); + + $publicJwk = [ + 'kty' => 'RSA', + 'kid' => $kid, + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $n, + 'e' => $e, + ]; + + return [$privateKeyPem, $publicJwk]; + } + + private function b64urlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * @param list $responses + */ + private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface + { + $expectedCalls ??= count($responses); + + $httpClient = $this->createMock(ClientInterface::class); + $expectation = $httpClient + ->expects($this->exactly($expectedCalls)) + ->method('sendRequest') + ->with($this->isInstanceOf(RequestInterface::class)); + + if (1 === $expectedCalls) { + $expectation->willReturn($responses[0]); + } else { + // If expectedCalls > count(responses), keep returning the last response. + $sequence = $responses; + while (count($sequence) < $expectedCalls) { + $sequence[] = $responses[array_key_last($responses)]; + } + $expectation->willReturnOnConsecutiveCalls(...$sequence); + } + + return $httpClient; + } +} From 446569ad08fd3b71d082d7537ec6283e8c65dfd9 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 29 Dec 2025 16:04:50 +0100 Subject: [PATCH 04/18] Add Middleware handlers to StreamableHttpTransport --- src/Server/Transport/StreamableHttpTransport.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 9f557f37..2e4f72c2 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -20,6 +20,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; From 81c94bcc6cfcc3ae7f8f6824e7e524f6b85cf827 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 12 Jan 2026 12:40:25 +0100 Subject: [PATCH 05/18] OAuth Implementation based on middleware --- composer.json | 5 + examples/server/oauth-keycloak/Dockerfile | 23 + .../server/oauth-keycloak/McpElements.php | 109 +++++ examples/server/oauth-keycloak/README.md | 136 ++++++ .../server/oauth-keycloak/docker-compose.yml | 61 +++ .../oauth-keycloak/keycloak/mcp-realm.json | 128 ++++++ .../server/oauth-keycloak/nginx/default.conf | 25 ++ examples/server/oauth-keycloak/server.php | 96 +++++ examples/server/oauth-microsoft/Dockerfile | 23 + .../server/oauth-microsoft/McpElements.php | 126 ++++++ examples/server/oauth-microsoft/README.md | 213 +++++++++ .../server/oauth-microsoft/docker-compose.yml | 32 ++ examples/server/oauth-microsoft/env.example | 18 + .../server/oauth-microsoft/nginx/default.conf | 25 ++ examples/server/oauth-microsoft/server.php | 110 +++++ .../Middleware/AuthorizationMiddleware.php | 304 +++++++++++++ .../Middleware/AuthorizationResult.php | 138 ++++++ .../AuthorizationTokenValidatorInterface.php | 35 ++ .../Middleware/JwtTokenValidator.php | 407 ++++++++++++++++++ .../Middleware/OAuthProxyMiddleware.php | 234 ++++++++++ .../Transport/Middleware/OidcDiscovery.php | 291 +++++++++++++ .../Middleware/ProtectedResourceMetadata.php | 88 ++++ 22 files changed, 2627 insertions(+) create mode 100644 examples/server/oauth-keycloak/Dockerfile create mode 100644 examples/server/oauth-keycloak/McpElements.php create mode 100644 examples/server/oauth-keycloak/README.md create mode 100644 examples/server/oauth-keycloak/docker-compose.yml create mode 100644 examples/server/oauth-keycloak/keycloak/mcp-realm.json create mode 100644 examples/server/oauth-keycloak/nginx/default.conf create mode 100644 examples/server/oauth-keycloak/server.php create mode 100644 examples/server/oauth-microsoft/Dockerfile create mode 100644 examples/server/oauth-microsoft/McpElements.php create mode 100644 examples/server/oauth-microsoft/README.md create mode 100644 examples/server/oauth-microsoft/docker-compose.yml create mode 100644 examples/server/oauth-microsoft/env.example create mode 100644 examples/server/oauth-microsoft/nginx/default.conf create mode 100644 examples/server/oauth-microsoft/server.php create mode 100644 src/Server/Transport/Middleware/AuthorizationMiddleware.php create mode 100644 src/Server/Transport/Middleware/AuthorizationResult.php create mode 100644 src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php create mode 100644 src/Server/Transport/Middleware/JwtTokenValidator.php create mode 100644 src/Server/Transport/Middleware/OAuthProxyMiddleware.php create mode 100644 src/Server/Transport/Middleware/OidcDiscovery.php create mode 100644 src/Server/Transport/Middleware/ProtectedResourceMetadata.php diff --git a/composer.json b/composer.json index c94820f3..437e6135 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", @@ -35,6 +36,8 @@ "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "require-dev": { + "firebase/php-jwt": "^6.10", + "guzzlehttp/guzzle": "^7.0", "laminas/laminas-httphandlerrunner": "^2.12", "nyholm/psr7": "^1.8", "nyholm/psr7-server": "^1.1", @@ -67,6 +70,8 @@ "Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/", "Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/", "Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/", + "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", + "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Tests\\": "tests/" } diff --git a/examples/server/oauth-keycloak/Dockerfile b/examples/server/oauth-keycloak/Dockerfile new file mode 100644 index 00000000..34b5d540 --- /dev/null +++ b/examples/server/oauth-keycloak/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.2-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + curl \ + git \ + unzip + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Install PHP extensions +RUN docker-php-ext-install opcache + +# Configure PHP-FPM to listen on TCP +RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/examples/server/oauth-keycloak/McpElements.php b/examples/server/oauth-keycloak/McpElements.php new file mode 100644 index 00000000..d2b07562 --- /dev/null +++ b/examples/server/oauth-keycloak/McpElements.php @@ -0,0 +1,109 @@ + true, + 'message' => 'You have successfully authenticated with OAuth!', + 'timestamp' => date('c'), + 'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.', + ]; + } + + /** + * Simulates calling a protected external API. + */ + #[McpTool( + name: 'call_protected_api', + description: 'Simulate calling a protected external API endpoint' + )] + public function callProtectedApi( + string $endpoint, + string $method = 'GET', + ): array { + // In a real implementation, you would: + // 1. Use token exchange to get a token for the downstream API + // 2. Or use client credentials with the user's context + // 3. Make the actual HTTP call to the protected API + + return [ + 'status' => 'success', + 'message' => sprintf('Simulated %s request to %s', $method, $endpoint), + 'simulated_response' => [ + 'data' => 'This is simulated data from the protected API', + 'timestamp' => date('c'), + ], + ]; + } + + /** + * Returns the current server time and status. + */ + #[McpResource( + uri: 'server://status', + name: 'server_status', + description: 'Current server status (protected resource)', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'healthy', + 'timestamp' => date('c'), + 'php_version' => PHP_VERSION, + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'protected' => true, + ]; + } + + /** + * A greeting prompt. + */ + #[McpPrompt( + name: 'greeting', + description: 'Generate a greeting message' + )] + public function greeting(string $style = 'formal'): string + { + return match ($style) { + 'casual' => "Hey there! Welcome to the protected MCP server!", + 'formal' => "Good day. Welcome to the OAuth-protected MCP server.", + 'friendly' => "Hello! Great to have you here!", + default => "Welcome to the MCP server!", + }; + } +} diff --git a/examples/server/oauth-keycloak/README.md b/examples/server/oauth-keycloak/README.md new file mode 100644 index 00000000..fb3029be --- /dev/null +++ b/examples/server/oauth-keycloak/README.md @@ -0,0 +1,136 @@ +# OAuth Keycloak Example + +This example demonstrates MCP server authorization using Keycloak as the OAuth 2.0 / OpenID Connect provider. + +## Features + +- JWT token validation with automatic JWKS discovery +- Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource` +- MCP tools protected by OAuth authentication +- Pre-configured Keycloak realm with test user + +## Quick Start + +1. **Start the services:** + +```bash +docker compose up -d +``` + +2. **Wait for Keycloak to be ready** (may take 30-60 seconds): + +```bash +docker compose logs -f keycloak +# Wait until you see "Running the server in development mode" +``` + +3. **Get an access token:** + +```bash +# Using Resource Owner Password Credentials (for testing only) +TOKEN=$(curl -s -X POST "http://localhost:8180/realms/mcp/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=mcp-client" \ + -d "username=demo" \ + -d "password=demo123" \ + -d "grant_type=password" \ + -d "scope=openid mcp" | jq -r '.access_token') + +echo $TOKEN +``` + +4. **Test the MCP server:** + +```bash +# Get Protected Resource Metadata +curl http://localhost:8000/.well-known/oauth-protected-resource + +# Call MCP endpoint without token (should get 401) +curl -i http://localhost:8000/mcp + +# Call MCP endpoint with token +curl -X POST http://localhost:8000/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +5. **Use with MCP Inspector:** + +The MCP Inspector doesn't support OAuth out of the box, but you can test using curl or build a custom client. + +## Keycloak Configuration + +The realm is pre-configured with: + +| Item | Value | +|------|-------| +| Realm | `mcp` | +| Client (public) | `mcp-client` | +| Client (resource) | `mcp-server` | +| Test User | `demo` / `demo123` | +| Scopes | `mcp:read`, `mcp:write` | + +### Keycloak Admin Console + +Access at http://localhost:8180/admin with: +- Username: `admin` +- Password: `admin` + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │ +│ │ │ (port 8000) │ │ MCP Server │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ Get Token │ Validate JWT + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Keycloak │◀───────────────────────────│ JWKS Fetch │ +│ (port 8180) │ │ │ +└─────────────────┘ └─────────────────┘ +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration +- `Dockerfile` - PHP-FPM container with dependencies +- `nginx/default.conf` - Nginx configuration for MCP endpoint +- `keycloak/mcp-realm.json` - Pre-configured Keycloak realm +- `server.php` - MCP server with OAuth middleware +- `McpElements.php` - MCP tools and resources + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `KEYCLOAK_EXTERNAL_URL` | `http://localhost:8180` | Keycloak URL as seen by clients (token issuer) | +| `KEYCLOAK_INTERNAL_URL` | `http://keycloak:8080` | Keycloak URL from within Docker network (for JWKS) | +| `KEYCLOAK_REALM` | `mcp` | Keycloak realm name | +| `MCP_AUDIENCE` | `mcp-server` | Expected JWT audience | + +## Troubleshooting + +### Token validation fails + +1. Ensure Keycloak is fully started (check health endpoint) +2. Verify the token hasn't expired (default: 5 minutes) +3. Check that the audience claim matches `mcp-server` + +### Connection refused + +1. Wait for Keycloak health check to pass +2. Check Docker network connectivity: `docker compose logs` + +### JWKS fetch fails + +The MCP server needs to reach Keycloak at `http://keycloak:8080` (Docker network). +For local development outside Docker, use `http://localhost:8180`. + +## Cleanup + +```bash +docker compose down -v +``` diff --git a/examples/server/oauth-keycloak/docker-compose.yml b/examples/server/oauth-keycloak/docker-compose.yml new file mode 100644 index 00000000..2dca5b2c --- /dev/null +++ b/examples/server/oauth-keycloak/docker-compose.yml @@ -0,0 +1,61 @@ +services: + keycloak: + image: quay.io/keycloak/keycloak:24.0 + container_name: mcp-keycloak + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + volumes: + - ./keycloak/mcp-realm.json:/opt/keycloak/data/import/mcp-realm.json:ro + command: + - start-dev + - --import-realm + ports: + - "8180:8080" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s + networks: + - mcp-network + + php: + build: + context: . + dockerfile: Dockerfile + container_name: mcp-php + volumes: + - ../../../:/app + working_dir: /app + environment: + KEYCLOAK_EXTERNAL_URL: http://localhost:8180 + KEYCLOAK_INTERNAL_URL: http://keycloak:8080 + KEYCLOAK_REALM: mcp + MCP_AUDIENCE: mcp-server + depends_on: + keycloak: + condition: service_healthy + command: > + sh -c "composer install --no-interaction --quiet 2>/dev/null || true && php-fpm" + networks: + - mcp-network + + nginx: + image: nginx:alpine + container_name: mcp-nginx + ports: + - "8000:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../../../:/app:ro + depends_on: + - php + networks: + - mcp-network + +networks: + mcp-network: + driver: bridge diff --git a/examples/server/oauth-keycloak/keycloak/mcp-realm.json b/examples/server/oauth-keycloak/keycloak/mcp-realm.json new file mode 100644 index 00000000..55d28751 --- /dev/null +++ b/examples/server/oauth-keycloak/keycloak/mcp-realm.json @@ -0,0 +1,128 @@ +{ + "realm": "mcp", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "clients": [ + { + "clientId": "mcp-client", + "name": "MCP Client Application", + "description": "Public client for MCP client applications", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "fullScopeAllowed": true, + "redirectUris": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "webOrigins": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "defaultClientScopes": [ + "openid", + "profile", + "email", + "mcp" + ], + "optionalClientScopes": [], + "attributes": { + "pkce.code.challenge.method": "S256" + } + }, + { + "clientId": "mcp-server", + "name": "MCP Server Resource", + "description": "Resource server representing the MCP server", + "enabled": true, + "publicClient": false, + "bearerOnly": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false + } + ], + "clientScopes": [ + { + "name": "mcp", + "description": "MCP access scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Access to MCP server resources" + }, + "protocolMappers": [ + { + "name": "mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "mcp-server", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "mcp-scopes", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "scope", + "claim.value": "mcp:read mcp:write", + "jsonType.label": "String", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ] + } + ], + "users": [ + { + "username": "demo", + "email": "demo@example.com", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "demo123", + "temporary": false + } + ], + "realmRoles": ["default-roles-mcp"] + } + ], + "defaultDefaultClientScopes": [ + "openid", + "profile", + "email" + ], + "roles": { + "realm": [ + { + "name": "default-roles-mcp", + "description": "Default roles for MCP realm", + "composite": false + } + ] + } +} diff --git a/examples/server/oauth-keycloak/nginx/default.conf b/examples/server/oauth-keycloak/nginx/default.conf new file mode 100644 index 00000000..f7a265ad --- /dev/null +++ b/examples/server/oauth-keycloak/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /app/examples/server/oauth-keycloak; + + # Route all requests through PHP + location / { + try_files $uri /server.php$is_args$args; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index server.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Pass all request info + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + } +} diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php new file mode 100644 index 00000000..f3f8b05f --- /dev/null +++ b/examples/server/oauth-keycloak/server.php @@ -0,0 +1,96 @@ +createServerRequestFromGlobals(); + +// Create JWT validator +// - issuer: matches what's in the token (external URL) +// - jwksUri: where to fetch keys (internal URL) +$validator = new JwtTokenValidator( + issuer: $issuer, + audience: $mcpAudience, + jwksUri: $jwksUri, +); + +// Create Protected Resource Metadata (RFC 9728) +// Authorization server URL should be the external URL for clients +// scopesSupported must match what Keycloak's mcp-client allows +$metadata = new ProtectedResourceMetadata( + authorizationServers: [$issuer], + scopesSupported: ['openid'], + resource: 'http://localhost:8000/mcp', +); + +// Create authorization middleware +$authMiddleware = new AuthorizationMiddleware( + metadata: $metadata, + validator: $validator, + metadataPaths: ['/.well-known/oauth-protected-resource'], +); + +// Build MCP server +$server = Server::builder() + ->setServerInfo('OAuth Keycloak Example', '1.0.0') + ->setLogger($logger) + ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setDiscovery(__DIR__) + ->build(); + +// Create transport with authorization middleware +$transport = new StreamableHttpTransport( + $request, + logger: $logger, + middlewares: [$authMiddleware], +); + +// Run server +$response = $server->run($transport); + +// Emit response +(new SapiEmitter())->emit($response); diff --git a/examples/server/oauth-microsoft/Dockerfile b/examples/server/oauth-microsoft/Dockerfile new file mode 100644 index 00000000..34b5d540 --- /dev/null +++ b/examples/server/oauth-microsoft/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.2-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + curl \ + git \ + unzip + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Install PHP extensions +RUN docker-php-ext-install opcache + +# Configure PHP-FPM to listen on TCP +RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/examples/server/oauth-microsoft/McpElements.php b/examples/server/oauth-microsoft/McpElements.php new file mode 100644 index 00000000..c2d27f7c --- /dev/null +++ b/examples/server/oauth-microsoft/McpElements.php @@ -0,0 +1,126 @@ + true, + 'provider' => 'Microsoft Entra ID', + 'message' => 'You have successfully authenticated with Microsoft!', + 'timestamp' => date('c'), + ]; + } + + /** + * Simulates calling Microsoft Graph API. + */ + #[McpTool( + name: 'call_graph_api', + description: 'Simulate calling Microsoft Graph API' + )] + public function callGraphApi( + string $endpoint = '/me', + ): array { + // In a real implementation, you would: + // 1. Use the On-Behalf-Of flow to exchange tokens + // 2. Call Microsoft Graph with the new token + + return [ + 'status' => 'simulated', + 'endpoint' => "https://graph.microsoft.com/v1.0{$endpoint}", + 'message' => 'Configure AZURE_CLIENT_SECRET for actual Graph API calls', + 'simulated_response' => [ + 'displayName' => 'Demo User', + 'mail' => 'demo@example.com', + ], + ]; + } + + /** + * Lists simulated emails. + */ + #[McpTool( + name: 'list_emails', + description: 'List recent emails (simulated)' + )] + public function listEmails(int $count = 5): array + { + return [ + 'note' => 'Simulated data. Implement Graph API call with Mail.Read scope for real emails.', + 'emails' => array_map(fn ($i) => [ + 'id' => 'msg_'.uniqid(), + 'subject' => "Sample Email #{$i}", + 'from' => "sender{$i}@example.com", + 'receivedDateTime' => date('c', strtotime("-{$i} hours")), + ], range(1, $count)), + ]; + } + + /** + * Returns the current server status. + */ + #[McpResource( + uri: 'server://status', + name: 'server_status', + description: 'Current server status with Microsoft auth info', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'healthy', + 'timestamp' => date('c'), + 'auth_provider' => 'Microsoft Entra ID', + 'php_version' => PHP_VERSION, + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + ]; + } + + /** + * A Microsoft Teams-style message prompt. + */ + #[McpPrompt( + name: 'teams_message', + description: 'Generate a Microsoft Teams-style message' + )] + public function teamsMessage(string $messageType = 'announcement'): string + { + return match ($messageType) { + 'announcement' => "📢 **Announcement**\n\nPlease add your announcement content here.", + 'question' => "❓ **Question**\n\nType your question here.", + 'update' => "📋 **Status Update**\n\n**Progress:**\n- Item 1\n- Item 2", + default => "💬 **Message**\n\nYour message content here.", + }; + } +} diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md new file mode 100644 index 00000000..0a98121f --- /dev/null +++ b/examples/server/oauth-microsoft/README.md @@ -0,0 +1,213 @@ +# OAuth Microsoft Entra ID Example + +This example demonstrates MCP server authorization using Microsoft Entra ID (formerly Azure AD) as the OAuth 2.0 / OpenID Connect provider. + +## Features + +- JWT token validation with Microsoft Entra ID +- Protected Resource Metadata (RFC 9728) +- MCP tools that access Microsoft claims +- Optional Microsoft Graph API integration + +## Prerequisites + +1. **Azure Subscription** with access to Entra ID +2. **App Registration** in Azure Portal + +## Azure Setup + +### 1. Create App Registration + +1. Go to [Azure Portal](https://portal.azure.com) > **Entra ID** > **App registrations** +2. Click **New registration** +3. Configure: + - **Name**: `MCP Server` + - **Supported account types**: Choose based on your needs + - **Redirect URI**: Leave empty for now (this is a resource server) +4. Click **Register** + +### 2. Configure the App + +After registration: + +1. **Copy values for `.env`**: + - **Application (client) ID** → `AZURE_CLIENT_ID` + - **Directory (tenant) ID** → `AZURE_TENANT_ID` + +2. **Expose an API** (optional, for custom scopes): + - Go to **Expose an API** + - Set **Application ID URI** (e.g., `api://your-client-id`) + - Add scopes like `mcp.read`, `mcp.write` + +3. **Create client secret** (for Graph API calls): + - Go to **Certificates & secrets** + - Click **New client secret** + - Copy the secret value → `AZURE_CLIENT_SECRET` + +4. **API Permissions** (for Graph API): + - Go to **API permissions** + - Add **Microsoft Graph** > **Delegated permissions**: + - `User.Read` (for profile) + - `Mail.Read` (for emails, optional) + - Grant admin consent if required + +### 3. Create a Client App (for testing) + +Create a separate app registration for the client: + +1. **New registration**: + - **Name**: `MCP Client` + - **Redirect URI**: `http://localhost` (Public client/native) + +2. **Authentication**: + - Enable **Allow public client flows** for PKCE + +3. **API permissions**: + - Add permission to your MCP Server app's exposed API + +## Quick Start + +1. **Copy environment file:** + +```bash +cp env.example .env +``` + +2. **Edit `.env` with your Azure values:** + +```bash +AZURE_TENANT_ID=your-tenant-id +AZURE_CLIENT_ID=your-client-id +AZURE_CLIENT_SECRET=your-client-secret # Optional, for Graph API +``` + +3. **Start the services:** + +```bash +docker compose up -d +``` + +4. **Get an access token:** + +Using Azure CLI: +```bash +# Login +az login + +# Get token for your app +TOKEN=$(az account get-access-token \ + --resource api://your-client-id \ + --query accessToken -o tsv) +``` + +Or using MSAL / OAuth flow in your client application. + +5. **Test the MCP server:** + +```bash +# Get Protected Resource Metadata +curl http://localhost:8000/.well-known/oauth-protected-resource + +# Call MCP endpoint without token (should get 401) +curl -i http://localhost:8000/mcp + +# Call MCP endpoint with token +curl -X POST http://localhost:8000/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │ +│ │ │ (port 8000) │ │ MCP Server │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ Get Token │ Validate JWT + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Microsoft │◀───────────────────────────│ JWKS Fetch │ +│ Entra ID │ │ │ +└─────────────────┘ └─────────────────┘ + │ + │ (Optional) Graph API + ▼ +┌─────────────────┐ +│ Microsoft │ +│ Graph API │ +└─────────────────┘ +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration +- `Dockerfile` - PHP-FPM container +- `nginx/default.conf` - Nginx configuration +- `env.example` - Environment variables template +- `server.php` - MCP server with OAuth middleware +- `McpElements.php` - MCP tools including Graph API integration + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `AZURE_TENANT_ID` | Yes | Azure AD tenant ID | +| `AZURE_CLIENT_ID` | Yes | Application (client) ID | +| `AZURE_CLIENT_SECRET` | No | Client secret for Graph API calls | + +## Microsoft Token Structure + +Microsoft Entra ID tokens include these common claims: + +| Claim | Description | +|-------|-------------| +| `oid` | Object ID (unique user identifier in tenant) | +| `tid` | Tenant ID | +| `sub` | Subject (unique user identifier) | +| `name` | Display name | +| `preferred_username` | Usually the UPN | +| `email` | Email address (if available) | +| `upn` | User Principal Name | + +## Troubleshooting + +### "Invalid issuer" error + +Microsoft uses different issuer URLs depending on the token flow: +- v2.0 endpoint (user/delegated flows): `https://login.microsoftonline.com/{tenant}/v2.0` +- v1.0 endpoint (client credentials flow): `https://sts.windows.net/{tenant}/` + +This example **automatically accepts both formats** by configuring multiple issuers in the `JwtTokenValidator`. +Check your token's `iss` claim to verify which format is being used. + +### "Invalid audience" error + +The `aud` claim must match `AZURE_CLIENT_ID`. For v2.0 tokens with custom scopes, +the audience might be `api://your-client-id`. + +### JWKS fetch fails + +Microsoft's JWKS endpoint is public. Ensure your container can reach: +`https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys` + +### Graph API errors + +1. Ensure `AZURE_CLIENT_SECRET` is set +2. Verify API permissions have admin consent +3. Check that the user exists in your tenant + +## Security Notes + +1. **Never commit `.env` files** - they contain secrets +2. **Use managed identities** in Azure deployments instead of client secrets +3. **Implement proper token refresh** in production clients +4. **Validate scopes** for sensitive operations + +## Cleanup + +```bash +docker compose down -v +``` diff --git a/examples/server/oauth-microsoft/docker-compose.yml b/examples/server/oauth-microsoft/docker-compose.yml new file mode 100644 index 00000000..c4312d71 --- /dev/null +++ b/examples/server/oauth-microsoft/docker-compose.yml @@ -0,0 +1,32 @@ +services: + php: + build: + context: . + dockerfile: Dockerfile + container_name: mcp-php-microsoft + volumes: + - ../../../:/app:ro + - ./server.php:/app/examples/server/oauth-microsoft/server.php:ro + - ./McpElements.php:/app/examples/server/oauth-microsoft/McpElements.php:ro + environment: + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + networks: + - mcp-network + + nginx: + image: nginx:alpine + container_name: mcp-nginx-microsoft + ports: + - "8000:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - php + networks: + - mcp-network + +networks: + mcp-network: + driver: bridge diff --git a/examples/server/oauth-microsoft/env.example b/examples/server/oauth-microsoft/env.example new file mode 100644 index 00000000..7ce041f4 --- /dev/null +++ b/examples/server/oauth-microsoft/env.example @@ -0,0 +1,18 @@ +# Microsoft Entra ID (Azure AD) Configuration +# Copy this file to .env and fill in your values + +# Your Azure AD tenant ID +# Find at: Azure Portal > Entra ID > Overview > Tenant ID +AZURE_TENANT_ID=your-tenant-id-here + +# Application (client) ID for the MCP server app registration +# This is the audience that tokens must be issued for +AZURE_CLIENT_ID=your-client-id-here + +# Client secret for calling Microsoft Graph API (optional) +# Only needed if your MCP tools call Graph API on behalf of users +AZURE_CLIENT_SECRET=your-client-secret-here + +# Optional: Specific API permissions/scopes your MCP server accepts +# Comma-separated list of custom scopes defined in your app registration +# MCP_SCOPES=api://your-client-id/mcp.read,api://your-client-id/mcp.write diff --git a/examples/server/oauth-microsoft/nginx/default.conf b/examples/server/oauth-microsoft/nginx/default.conf new file mode 100644 index 00000000..ad990152 --- /dev/null +++ b/examples/server/oauth-microsoft/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /app/examples/server/oauth-microsoft; + + # Route all requests through PHP + location / { + try_files $uri /server.php$is_args$args; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index server.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Pass all request info + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + } +} diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php new file mode 100644 index 00000000..da5c5cc7 --- /dev/null +++ b/examples/server/oauth-microsoft/server.php @@ -0,0 +1,110 @@ +createServerRequestFromGlobals(); + +// Create JWT validator for Microsoft Entra ID +// Microsoft uses the client ID as the audience for access tokens +// Accept both v1.0 and v2.0 issuers to support various token flows +$validator = new JwtTokenValidator( + issuer: $issuers, + audience: $clientId, + // Microsoft's JWKS endpoint - use common endpoint for all Microsoft signing keys + jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys', +); + +// Create Protected Resource Metadata (RFC 9728) +// Point to local authorization server (which proxies to Microsoft) +// This allows mcp-remote to use our /authorize and /token endpoints +$metadata = new ProtectedResourceMetadata( + authorizationServers: ['http://localhost:8000'], + scopesSupported: ['openid', 'profile', 'email'], + resource: null, +); + +// Get client secret for confidential client flow +$clientSecret = getenv('AZURE_CLIENT_SECRET') ?: null; + +// Create OAuth proxy middleware to handle /authorize and /token endpoints +// This proxies OAuth requests to Microsoft Entra ID +// The clientSecret is injected server-side since mcp-remote doesn't have access to it +$oauthProxyMiddleware = new OAuthProxyMiddleware( + upstreamIssuer: $issuerV2, + localBaseUrl: 'http://localhost:8000', + clientSecret: $clientSecret, +); + +// Create authorization middleware +$authMiddleware = new AuthorizationMiddleware( + metadata: $metadata, + validator: $validator, + metadataPaths: ['/.well-known/oauth-protected-resource'], +); + +// Build MCP server +$server = Server::builder() + ->setServerInfo('OAuth Microsoft Example', '1.0.0') + ->setLogger($logger) + ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setDiscovery(__DIR__) + ->build(); + +// Create transport with OAuth proxy and authorization middlewares +// Middlewares are reversed internally, so put OAuth proxy FIRST to execute FIRST +$transport = new StreamableHttpTransport( + $request, + logger: $logger, + middlewares: [$oauthProxyMiddleware, $authMiddleware], +); + +// Run server +$response = $server->run($transport); + +// Emit response +(new SapiEmitter())->emit($response); diff --git a/src/Server/Transport/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Middleware/AuthorizationMiddleware.php new file mode 100644 index 00000000..6bf6319b --- /dev/null +++ b/src/Server/Transport/Middleware/AuthorizationMiddleware.php @@ -0,0 +1,304 @@ + + */ +final class AuthorizationMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + /** @var list */ + private array $metadataPaths; + + /** @var callable(ServerRequestInterface): list|null */ + private $scopeProvider; + + /** + * @param ProtectedResourceMetadata $metadata The protected resource metadata to serve + * @param AuthorizationTokenValidatorInterface $validator Token validator implementation + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * @param list $metadataPaths Paths where metadata should be served (e.g., ["/.well-known/oauth-protected-resource"]) + * @param string|null $resourceMetadataUrl Explicit URL for the resource_metadata in WWW-Authenticate + * @param callable(ServerRequestInterface): list|null $scopeProvider Optional callback to determine required scopes per request + */ + public function __construct( + private ProtectedResourceMetadata $metadata, + private AuthorizationTokenValidatorInterface $validator, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + array $metadataPaths = [], + private ?string $resourceMetadataUrl = null, + ?callable $scopeProvider = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + + $this->metadataPaths = $this->normalizePaths($metadataPaths); + $this->scopeProvider = $scopeProvider; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // Serve metadata at well-known paths + if ($this->isMetadataRequest($request)) { + return $this->createMetadataResponse(); + } + + // Extract Authorization header + $authorization = $request->getHeaderLine('Authorization'); + if ('' === $authorization) { + return $this->buildErrorResponse($request, AuthorizationResult::unauthorized()); + } + + // Parse Bearer token + $accessToken = $this->parseBearerToken($authorization); + if (null === $accessToken) { + return $this->buildErrorResponse( + $request, + AuthorizationResult::badRequest('invalid_request', 'Malformed Authorization header.'), + ); + } + + // Validate the token + $result = $this->validator->validate($request, $accessToken); + if ($result->isAllowed()) { + return $handler->handle($this->applyAttributes($request, $result->getAttributes())); + } + + return $this->buildErrorResponse($request, $result); + } + + private function createMetadataResponse(): ResponseInterface + { + $payload = $this->metadata->toJson(); + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); + } + + private function isMetadataRequest(ServerRequestInterface $request): bool + { + if (empty($this->metadataPaths)) { + return false; + } + + if ('GET' !== $request->getMethod()) { + return false; + } + + $path = $request->getUri()->getPath(); + + foreach ($this->metadataPaths as $metadataPath) { + if ($path === $metadataPath) { + return true; + } + } + + return false; + } + + private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface + { + $response = $this->responseFactory->createResponse($result->getStatusCode()); + $header = $this->buildAuthenticateHeader($request, $result); + + if (null !== $header) { + $response = $response->withHeader('WWW-Authenticate', $header); + } + + return $response; + } + + private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): ?string + { + $parts = []; + + // Include resource_metadata URL per RFC 9728 + $resourceMetadataUrl = $this->resolveResourceMetadataUrl($request); + if (null !== $resourceMetadataUrl) { + $parts[] = 'resource_metadata="' . $this->escapeHeaderValue($resourceMetadataUrl) . '"'; + } + + // Include scope hint per RFC 6750 Section 3 + $scopes = $this->resolveScopes($request, $result); + if (!empty($scopes)) { + $parts[] = 'scope="' . $this->escapeHeaderValue(implode(' ', $scopes)) . '"'; + } + + // Include error details + if (null !== $result->getError()) { + $parts[] = 'error="' . $this->escapeHeaderValue($result->getError()) . '"'; + } + + if (null !== $result->getErrorDescription()) { + $parts[] = 'error_description="' . $this->escapeHeaderValue($result->getErrorDescription()) . '"'; + } + + if (empty($parts)) { + return 'Bearer'; + } + + return 'Bearer ' . implode(', ', $parts); + } + + /** + * @return list|null + */ + private function resolveScopes(ServerRequestInterface $request, AuthorizationResult $result): ?array + { + // First, check if the result has specific scopes (e.g., from insufficient_scope error) + $scopes = $this->normalizeScopes($result->getScopes()); + if (null !== $scopes) { + return $scopes; + } + + // Then, check the scope provider callback + if (null !== $this->scopeProvider) { + $provided = ($this->scopeProvider)($request); + $scopes = $this->normalizeScopes($provided); + if (null !== $scopes) { + return $scopes; + } + } + + // Fall back to scopes from metadata + return $this->normalizeScopes($this->metadata->getScopesSupported()); + } + + /** + * @param list|null $scopes + * + * @return list|null + */ + private function normalizeScopes(?array $scopes): ?array + { + if (null === $scopes) { + return null; + } + + $normalized = array_values(array_filter(array_map('trim', $scopes), static function (string $scope): bool { + return '' !== $scope; + })); + + return empty($normalized) ? null : $normalized; + } + + private function resolveResourceMetadataUrl(ServerRequestInterface $request): ?string + { + // Use explicit URL if configured + if (null !== $this->resourceMetadataUrl) { + return $this->resourceMetadataUrl; + } + + // Auto-generate from request if metadata paths are configured + if (empty($this->metadataPaths)) { + return null; + } + + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + $host = $uri->getHost(); + + if ('' === $scheme || '' === $host) { + return null; + } + + $authority = $host; + $port = $uri->getPort(); + + if (null !== $port && !$this->isDefaultPort($scheme, $port)) { + $authority .= ':' . $port; + } + + return $scheme . '://' . $authority . $this->metadataPaths[0]; + } + + private function isDefaultPort(string $scheme, int $port): bool + { + return ('https' === $scheme && 443 === $port) || ('http' === $scheme && 80 === $port); + } + + /** + * @param array $attributes + */ + private function applyAttributes(ServerRequestInterface $request, array $attributes): ServerRequestInterface + { + foreach ($attributes as $name => $value) { + $request = $request->withAttribute($name, $value); + } + + return $request; + } + + /** + * @param list $paths + * + * @return list + */ + private function normalizePaths(array $paths): array + { + $normalized = []; + + foreach ($paths as $path) { + $path = trim($path); + if ('' === $path) { + continue; + } + if ('/' !== $path[0]) { + $path = '/' . $path; + } + $normalized[] = $path; + } + + return array_values(array_unique($normalized)); + } + + private function parseBearerToken(string $authorization): ?string + { + if (!preg_match('/^Bearer\\s+(.+)$/i', $authorization, $matches)) { + return null; + } + + $token = trim($matches[1]); + + return '' === $token ? null : $token; + } + + private function escapeHeaderValue(string $value): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + } +} diff --git a/src/Server/Transport/Middleware/AuthorizationResult.php b/src/Server/Transport/Middleware/AuthorizationResult.php new file mode 100644 index 00000000..3750b17d --- /dev/null +++ b/src/Server/Transport/Middleware/AuthorizationResult.php @@ -0,0 +1,138 @@ + + */ +class AuthorizationResult +{ + /** + * @param list|null $scopes Scopes to include in WWW-Authenticate challenge + * @param array $attributes Attributes to attach to the request on success + */ + private function __construct( + private readonly bool $allowed, + private readonly int $statusCode, + private readonly ?string $error, + private readonly ?string $errorDescription, + private readonly ?array $scopes, + private readonly array $attributes, + ) { + } + + /** + * Creates a result indicating access is allowed. + * + * @param array $attributes Attributes to attach to the request (e.g., user_id, scopes) + */ + public static function allow(array $attributes = []): self + { + return new self(true, 200, null, null, null, $attributes); + } + + /** + * Creates a result indicating the request is unauthorized (401). + * + * Use when no valid credentials are provided or the token is invalid. + * + * @param string|null $error OAuth error code (e.g., "invalid_token") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge + */ + public static function unauthorized( + ?string $error = null, + ?string $errorDescription = null, + ?array $scopes = null, + ): self + { + return new self(false, 401, $error, $errorDescription, $scopes, []); + } + + /** + * Creates a result indicating the request is forbidden (403). + * + * Use when the token is valid but lacks required permissions/scopes. + * + * @param string|null $error OAuth error code (defaults to "insufficient_scope") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge + */ + public static function forbidden( + ?string $error = 'insufficient_scope', + ?string $errorDescription = null, + ?array $scopes = null, + ): self + { + return new self(false, 403, $error ?? 'insufficient_scope', $errorDescription, $scopes, []); + } + + /** + * Creates a result indicating a bad request (400). + * + * Use when the Authorization header is malformed. + * + * @param string|null $error OAuth error code (defaults to "invalid_request") + * @param string|null $errorDescription Human-readable error description + */ + public static function badRequest( + ?string $error = 'invalid_request', + ?string $errorDescription = null, + ): self + { + return new self(false, 400, $error ?? 'invalid_request', $errorDescription, null, []); + } + + public function isAllowed(): bool + { + return $this->allowed; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getError(): ?string + { + return $this->error; + } + + public function getErrorDescription(): ?string + { + return $this->errorDescription; + } + + /** + * @return list|null + */ + public function getScopes(): ?array + { + return $this->scopes; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php b/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php new file mode 100644 index 00000000..6c315e72 --- /dev/null +++ b/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php @@ -0,0 +1,35 @@ + + */ +interface AuthorizationTokenValidatorInterface +{ + /** + * Validates an access token extracted from the Authorization header. + * + * @param ServerRequestInterface $request The incoming HTTP request + * @param string $accessToken The bearer token (without "Bearer " prefix) + * + * @return AuthorizationResult The result of the validation + */ + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult; +} diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php new file mode 100644 index 00000000..518db598 --- /dev/null +++ b/src/Server/Transport/Middleware/JwtTokenValidator.php @@ -0,0 +1,407 @@ + + */ +class JwtTokenValidator implements AuthorizationTokenValidatorInterface +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private ?OidcDiscovery $discovery = null; + + private const CACHE_KEY_PREFIX = 'mcp_jwt_jwks_'; + + /** + * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") For Microsoft Entra ID, you may need to provide both v1.0 and v2.0 issuers + * @param string|list $audience Expected audience(s) for the token + * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache PSR-16 cache for JWKS (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) + * @param string $scopeClaim Claim name for scopes (default: "scope") + */ + public function __construct( + private readonly string|array $issuer, + private readonly string|array $audience, + private readonly ?string $jwksUri = null, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + private readonly array $algorithms = ['RS256', 'RS384', 'RS512'], + private readonly string $scopeClaim = 'scope', + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + } + + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + // Decode header to see key ID + $parts = explode('.', $accessToken); + $header = null; + if (count($parts) >= 2) { + $header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true); + } + + // Microsoft Graph tokens have 'nonce' in header and cannot be verified externally + // These are opaque tokens meant only for Microsoft Graph API + if (isset($header['nonce'])) { + return $this->validateGraphToken($accessToken, $parts); + } + + try { + $keys = $this->getJwks(); + $decoded = JWT::decode($accessToken, $keys); + /** @var array $claims */ + $claims = (array)$decoded; + + // Validate issuer + if (!$this->validateIssuer($claims)) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token issuer mismatch.' + ); + } + + // Validate audience + if (!$this->validateAudience($claims)) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token audience mismatch.' + ); + } + + // Extract scopes + $scopes = $this->extractScopes($claims); + + // Build attributes to attach to request + $attributes = [ + 'oauth.claims' => $claims, + 'oauth.scopes' => $scopes, + ]; + + // Add common claims as individual attributes + if (isset($claims['sub'])) { + $attributes['oauth.subject'] = $claims['sub']; + } + + if (isset($claims['client_id'])) { + $attributes['oauth.client_id'] = $claims['client_id']; + } + + // Add azp (authorized party) for OIDC tokens + if (isset($claims['azp'])) { + $attributes['oauth.authorized_party'] = $claims['azp']; + } + + return AuthorizationResult::allow($attributes); + } catch (\Firebase\JWT\ExpiredException $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token has expired.' + ); + } catch (\Firebase\JWT\SignatureInvalidException $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token signature verification failed.' + ); + } catch (\Firebase\JWT\BeforeValidException $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token is not yet valid.' + ); + } catch (\UnexpectedValueException|\DomainException $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token validation failed: ' . $e->getMessage() + ); + } catch (\Throwable $e) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token validation error.' + ); + } + } + + /** + * Validates Microsoft Graph tokens that cannot be signature-verified externally. + * + * Microsoft Graph access tokens contain a 'nonce' in the header and use a special + * format where the signature cannot be verified by third parties. These tokens are + * meant only for Microsoft Graph API consumption. + * + * This method performs claim-based validation without signature verification. + * + * @param string $accessToken The JWT access token + * @param array $parts Token parts (header, payload, signature) + */ + private function validateGraphToken(string $accessToken, array $parts): AuthorizationResult + { + if (count($parts) < 2) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); + } + + try { + $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); + if (null === $payload) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.'); + } + + // Validate expiration + if (isset($payload['exp']) && $payload['exp'] < time()) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } + + // Validate not before + if (isset($payload['nbf']) && $payload['nbf'] > time() + 60) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); + } + + // For Graph tokens, we accept them if they came from Microsoft + // The issuer should be Microsoft's STS + $issuer = $payload['iss'] ?? ''; + if (!str_contains($issuer, 'sts.windows.net') && !str_contains($issuer, 'login.microsoftonline.com')) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.'); + } + + // Extract scopes + $scopes = $this->extractScopes($payload); + + // Build attributes + $attributes = [ + 'oauth.claims' => $payload, + 'oauth.scopes' => $scopes, + 'oauth.graph_token' => true, // Mark as Graph token + ]; + + if (isset($payload['sub'])) { + $attributes['oauth.subject'] = $payload['sub']; + } + + if (isset($payload['oid'])) { + $attributes['oauth.object_id'] = $payload['oid']; + } + + if (isset($payload['name'])) { + $attributes['oauth.name'] = $payload['name']; + } + + return AuthorizationResult::allow($attributes); + } catch (\Throwable $e) { + return AuthorizationResult::unauthorized('invalid_token', 'Graph token validation failed.'); + } + } + + /** + * Validates a token has the required scopes. + * + * Use this after validation to check specific scope requirements. + * + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation + * + * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise + */ + public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult + { + if (!$result->isAllowed()) { + return $result; + } + + $tokenScopes = $result->getAttributes()['oauth.scopes'] ?? []; + + if (!\is_array($tokenScopes)) { + $tokenScopes = []; + } + + foreach ($requiredScopes as $required) { + if (!\in_array($required, $tokenScopes, true)) { + return AuthorizationResult::forbidden( + 'insufficient_scope', + sprintf('Required scope: %s', $required), + $requiredScopes + ); + } + } + + return $result; + } + + /** + * @return array + */ + private function getJwks(): array + { + $jwksUri = $this->resolveJwksUri(); + $cacheKey = self::CACHE_KEY_PREFIX . hash('sha256', $jwksUri); + + $jwksData = null; + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if (\is_array($cached)) { + /** @var array $cached */ + $jwksData = $cached; + } + } + + if (null === $jwksData) { + $jwksData = $this->fetchJwks($jwksUri); + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $jwksData, $this->cacheTtl); + } + } + + /** @var array */ + return JWK::parseKeySet($jwksData, $this->algorithms[0]); + } + + private function resolveJwksUri(): string + { + if (null !== $this->jwksUri) { + return $this->jwksUri; + } + + // Auto-discover from first issuer + if (null === $this->discovery) { + $this->discovery = new OidcDiscovery( + $this->httpClient, + $this->requestFactory, + $this->cache, + $this->cacheTtl + ); + } + + $issuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; + + return $this->discovery->getJwksUri($issuers[0]); + } + + /** + * @param array $claims + */ + private function validateIssuer(array $claims): bool + { + if (!isset($claims['iss'])) { + return false; + } + + $tokenIssuer = $claims['iss']; + $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; + + return \in_array($tokenIssuer, $expectedIssuers, true); + } + + /** + * @return array + */ + private function fetchJwks(string $jwksUri): array + { + $request = $this->requestFactory->createRequest('GET', $jwksUri) + ->withHeader('Accept', 'application/json'); + + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() >= 400) { + throw new \RuntimeException(sprintf( + 'Failed to fetch JWKS from %s: HTTP %d', + $jwksUri, + $response->getStatusCode() + )); + } + + $body = (string)$response->getBody(); + + try { + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + } + + if (!\is_array($data) || !isset($data['keys'])) { + throw new \RuntimeException('Invalid JWKS format: missing "keys" array.'); + } + + /** @var array $data */ + return $data; + } + + /** + * @param array $claims + */ + private function validateAudience(array $claims): bool + { + if (!isset($claims['aud'])) { + return false; + } + + $tokenAudiences = \is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; + $expectedAudiences = \is_array($this->audience) ? $this->audience : [$this->audience]; + + foreach ($expectedAudiences as $expected) { + if (\in_array($expected, $tokenAudiences, true)) { + return true; + } + } + + return false; + } + + /** + * @param array $claims + * + * @return list + */ + private function extractScopes(array $claims): array + { + if (!isset($claims[$this->scopeClaim])) { + return []; + } + + $scopeValue = $claims[$this->scopeClaim]; + + if (\is_array($scopeValue)) { + return array_values(array_filter($scopeValue, 'is_string')); + } + + if (\is_string($scopeValue)) { + return array_values(array_filter(explode(' ', $scopeValue))); + } + + return []; + } +} diff --git a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php new file mode 100644 index 00000000..a0a5e5f1 --- /dev/null +++ b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php @@ -0,0 +1,234 @@ + + */ +final class OAuthProxyMiddleware implements MiddlewareInterface +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + private ?array $upstreamMetadata = null; + + /** + * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider + * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) + * @param string|null $clientSecret Optional client secret for confidential clients + */ + public function __construct( + private readonly string $upstreamIssuer, + private readonly string $localBaseUrl, + private readonly ?string $clientSecret = null, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $path = $request->getUri()->getPath(); + + // Serve local authorization server metadata + if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) { + return $this->createAuthServerMetadataResponse(); + } + + // Handle authorization endpoint - redirect to upstream + if ('GET' === $request->getMethod() && '/authorize' === $path) { + return $this->handleAuthorize($request); + } + + // Handle token endpoint - proxy to upstream + if ('POST' === $request->getMethod() && '/token' === $path) { + return $this->handleToken($request); + } + + // Pass through to next handler + return $handler->handle($request); + } + + private function handleAuthorize(ServerRequestInterface $request): ResponseInterface + { + $upstreamMetadata = $this->getUpstreamMetadata(); + $authorizationEndpoint = $upstreamMetadata['authorization_endpoint'] ?? null; + + if (null === $authorizationEndpoint) { + return $this->createErrorResponse(500, 'Upstream authorization endpoint not found'); + } + + // Get the raw query string to preserve exact encoding (important for PKCE) + $rawQueryString = $request->getUri()->getQuery(); + + // Build upstream URL preserving exact query string + $upstreamUrl = $authorizationEndpoint . '?' . $rawQueryString; + + // Redirect to upstream authorization server + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', $upstreamUrl) + ->withHeader('Cache-Control', 'no-store'); + } + + private function handleToken(ServerRequestInterface $request): ResponseInterface + { + $upstreamMetadata = $this->getUpstreamMetadata(); + $tokenEndpoint = $upstreamMetadata['token_endpoint'] ?? null; + + if (null === $tokenEndpoint) { + return $this->createErrorResponse(500, 'Upstream token endpoint not found'); + } + + // Get the request body and parse it + $body = (string)$request->getBody(); + parse_str($body, $params); + + // Inject client_secret if configured and not already present + if (null !== $this->clientSecret && !isset($params['client_secret'])) { + $params['client_secret'] = $this->clientSecret; + } + + // Rebuild body with potentially added client_secret + $body = http_build_query($params); + + // Create upstream request + $upstreamRequest = $this->requestFactory + ->createRequest('POST', $tokenEndpoint) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($this->streamFactory->createStream($body)); + + // Forward any Authorization header (for client credentials) + if ($request->hasHeader('Authorization')) { + $upstreamRequest = $upstreamRequest->withHeader('Authorization', $request->getHeaderLine('Authorization')); + } + + try { + $upstreamResponse = $this->httpClient->sendRequest($upstreamRequest); + $responseBody = (string)$upstreamResponse->getBody(); + + // Return upstream response as-is + return $this->responseFactory + ->createResponse($upstreamResponse->getStatusCode()) + ->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type')) + ->withHeader('Cache-Control', 'no-store') + ->withBody($this->streamFactory->createStream($responseBody)); + } catch (\Throwable $e) { + return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: ' . $e->getMessage()); + } + } + + private function createAuthServerMetadataResponse(): ResponseInterface + { + $upstreamMetadata = $this->getUpstreamMetadata(); + + // Create local metadata that points to our proxy endpoints + $localMetadata = [ + 'issuer' => $this->upstreamIssuer, + 'authorization_endpoint' => rtrim($this->localBaseUrl, '/') . '/authorize', + 'token_endpoint' => rtrim($this->localBaseUrl, '/') . '/token', + 'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'], + 'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], + ]; + + // Copy additional useful fields from upstream + $copyFields = [ + 'scopes_supported', + 'token_endpoint_auth_methods_supported', + 'jwks_uri', + ]; + + foreach ($copyFields as $field) { + if (isset($upstreamMetadata[$field])) { + $localMetadata[$field] = $upstreamMetadata[$field]; + } + } + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'max-age=3600') + ->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_UNESCAPED_SLASHES))); + } + + private function getUpstreamMetadata(): array + { + if (null !== $this->upstreamMetadata) { + return $this->upstreamMetadata; + } + + // Try OpenID Connect discovery first + $discoveryUrls = [ + rtrim($this->upstreamIssuer, '/') . '/.well-known/openid-configuration', + rtrim($this->upstreamIssuer, '/') . '/.well-known/oauth-authorization-server', + ]; + + foreach ($discoveryUrls as $url) { + try { + $request = $this->requestFactory->createRequest('GET', $url); + $response = $this->httpClient->sendRequest($request); + + if (200 === $response->getStatusCode()) { + $this->upstreamMetadata = json_decode((string)$response->getBody(), true) ?? []; + + return $this->upstreamMetadata; + } + } catch (\Throwable) { + // Try next URL + } + } + + $this->upstreamMetadata = []; + + return $this->upstreamMetadata; + } + + private function createErrorResponse(int $status, string $message): ResponseInterface + { + $body = json_encode(['error' => 'server_error', 'error_description' => $message]); + + return $this->responseFactory + ->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($body)); + } +} diff --git a/src/Server/Transport/Middleware/OidcDiscovery.php b/src/Server/Transport/Middleware/OidcDiscovery.php new file mode 100644 index 00000000..5124c83c --- /dev/null +++ b/src/Server/Transport/Middleware/OidcDiscovery.php @@ -0,0 +1,291 @@ + + */ +class OidcDiscovery +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + + private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; + + /** + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + */ + public function __construct( + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + } + + /** + * Discovers authorization server metadata from the issuer URL. + * + * Tries endpoints in priority order per RFC 8414 and OpenID Connect Discovery: + * 1. OAuth 2.0 path insertion: /.well-known/oauth-authorization-server/{path} + * 2. OIDC path insertion: /.well-known/openid-configuration/{path} + * 3. OIDC path appending: {path}/.well-known/openid-configuration + * + * @param string $issuer The issuer URL (e.g., "https://auth.example.com/realms/mcp") + * + * @return array The authorization server metadata + * + * @throws \RuntimeException If discovery fails + */ + public function discover(string $issuer): array + { + $cacheKey = self::CACHE_KEY_PREFIX . hash('sha256', $issuer); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if (\is_array($cached)) { + return $cached; + } + } + + $metadata = $this->fetchMetadata($issuer); + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $metadata, $this->cacheTtl); + } + + return $metadata; + } + + /** + * Gets the JWKS URI from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The JWKS URI + * + * @throws \RuntimeException If JWKS URI is not found in metadata + */ + public function getJwksUri(string $issuer): string + { + $metadata = $this->discover($issuer); + + if (!isset($metadata['jwks_uri']) || !\is_string($metadata['jwks_uri'])) { + throw new \RuntimeException('Authorization server metadata does not contain jwks_uri.'); + } + + return $metadata['jwks_uri']; + } + + /** + * Fetches JWKS (JSON Web Key Set) from the authorization server. + * + * @param string $issuer The issuer URL + * + * @return array The JWKS + * + * @throws \RuntimeException If fetching fails + */ + public function fetchJwks(string $issuer): array + { + $jwksUri = $this->getJwksUri($issuer); + + $cacheKey = self::CACHE_KEY_PREFIX . 'jwks_' . hash('sha256', $jwksUri); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if (\is_array($cached)) { + return $cached; + } + } + + $jwks = $this->fetchJson($jwksUri); + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $jwks, $this->cacheTtl); + } + + return $jwks; + } + + /** + * Checks if the authorization server supports PKCE. + * + * @param string $issuer The issuer URL + * + * @return bool True if PKCE is supported (code_challenge_methods_supported includes S256) + */ + public function supportsPkce(string $issuer): bool + { + $metadata = $this->discover($issuer); + + if (!isset($metadata['code_challenge_methods_supported']) || !\is_array($metadata['code_challenge_methods_supported'])) { + return false; + } + + return \in_array('S256', $metadata['code_challenge_methods_supported'], true); + } + + /** + * Gets the token endpoint from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The token endpoint URL + * + * @throws \RuntimeException If token endpoint is not found + */ + public function getTokenEndpoint(string $issuer): string + { + $metadata = $this->discover($issuer); + + if (!isset($metadata['token_endpoint']) || !\is_string($metadata['token_endpoint'])) { + throw new \RuntimeException('Authorization server metadata does not contain token_endpoint.'); + } + + return $metadata['token_endpoint']; + } + + /** + * Gets the authorization endpoint from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The authorization endpoint URL + * + * @throws \RuntimeException If authorization endpoint is not found + */ + public function getAuthorizationEndpoint(string $issuer): string + { + $metadata = $this->discover($issuer); + + if (!isset($metadata['authorization_endpoint']) || !\is_string($metadata['authorization_endpoint'])) { + throw new \RuntimeException('Authorization server metadata does not contain authorization_endpoint.'); + } + + return $metadata['authorization_endpoint']; + } + + /** + * @return array + */ + private function fetchMetadata(string $issuer): array + { + $issuer = rtrim($issuer, '/'); + $parsed = parse_url($issuer); + + if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { + throw new \RuntimeException(sprintf('Invalid issuer URL: %s', $issuer)); + } + + $scheme = $parsed['scheme']; + $host = $parsed['host']; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + $path = $parsed['path'] ?? ''; + + $baseUrl = $scheme . '://' . $host . $port; + + // Build discovery URLs in priority order per RFC 8414 Section 3.1 + $discoveryUrls = []; + + if ('' !== $path && '/' !== $path) { + // For issuer URLs with path components + // 1. OAuth 2.0 path insertion + $discoveryUrls[] = $baseUrl . '/.well-known/oauth-authorization-server' . $path; + // 2. OIDC path insertion + $discoveryUrls[] = $baseUrl . '/.well-known/openid-configuration' . $path; + // 3. OIDC path appending + $discoveryUrls[] = $issuer . '/.well-known/openid-configuration'; + } else { + // For issuer URLs without path components + $discoveryUrls[] = $baseUrl . '/.well-known/oauth-authorization-server'; + $discoveryUrls[] = $baseUrl . '/.well-known/openid-configuration'; + } + + $lastException = null; + + foreach ($discoveryUrls as $url) { + try { + $metadata = $this->fetchJson($url); + + // Validate issuer claim matches + if (isset($metadata['issuer']) && $metadata['issuer'] !== $issuer) { + continue; + } + + return $metadata; + } catch (\RuntimeException $e) { + $lastException = $e; + continue; + } + } + + throw new \RuntimeException( + sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), + 0, + $lastException + ); + } + + /** + * @return array + */ + private function fetchJson(string $url): array + { + $request = $this->requestFactory->createRequest('GET', $url) + ->withHeader('Accept', 'application/json'); + + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() >= 400) { + throw new \RuntimeException(sprintf( + 'HTTP request to %s failed with status %d', + $url, + $response->getStatusCode() + )); + } + + $body = (string)$response->getBody(); + + try { + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); + } + + if (!\is_array($data)) { + throw new \RuntimeException(sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); + } + + return $data; + } +} diff --git a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php new file mode 100644 index 00000000..067efa05 --- /dev/null +++ b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php @@ -0,0 +1,88 @@ + + */ +class ProtectedResourceMetadata +{ + /** + * @param list $authorizationServers URLs of authorization servers that can issue tokens for this resource + * @param list|null $scopesSupported OAuth scopes supported by this resource + * @param string|null $resource The resource identifier (typically the resource's URL) + * @param array $extra Additional metadata fields + */ + public function __construct( + private readonly array $authorizationServers, + private readonly ?array $scopesSupported = null, + private readonly ?string $resource = null, + private readonly array $extra = [], + ) { + if (empty($authorizationServers)) { + throw new \InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); + } + } + + /** + * @return list + */ + public function getAuthorizationServers(): array + { + return $this->authorizationServers; + } + + /** + * @return list|null + */ + public function getScopesSupported(): ?array + { + return $this->scopesSupported; + } + + public function getResource(): ?string + { + return $this->resource; + } + + /** + * @return array + */ + public function toArray(): array + { + $data = [ + 'authorization_servers' => array_values($this->authorizationServers), + ]; + + if (null !== $this->scopesSupported) { + $data['scopes_supported'] = array_values($this->scopesSupported); + } + + if (null !== $this->resource) { + $data['resource'] = $this->resource; + } + + return array_merge($this->extra, $data); + } + + public function toJson(): string + { + return json_encode($this->toArray(), \JSON_THROW_ON_ERROR); + } +} From eb2a422a2ce639991f45b40b34dbeb5ca6e02b6c Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Wed, 28 Jan 2026 16:31:42 +0100 Subject: [PATCH 06/18] OAuth Implementation based on middleware Signed-off-by: Volodymyr Panivko --- composer.json | 3 +- examples/server/oauth-keycloak/server.php | 28 +- examples/server/oauth-microsoft/server.php | 18 +- .../Middleware/AuthorizationMiddleware.php | 24 +- .../Middleware/AuthorizationResult.php | 2 +- .../Middleware/JwtTokenValidator.php | 50 +- .../Transport/StreamableHttpTransport.php | 1 - .../AuthorizationMiddlewareTest.php | 248 ++++++ .../Middleware/JwtTokenValidatorTest.php | 716 ++++++++++++++++++ 9 files changed, 1002 insertions(+), 88 deletions(-) create mode 100644 tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php diff --git a/composer.json b/composer.json index 437e6135..da4a29e3 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,8 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", - "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" + "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "ext-openssl": "*" }, "autoload": { "psr-4": { diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php index f3f8b05f..c8a7e667 100644 --- a/examples/server/oauth-keycloak/server.php +++ b/examples/server/oauth-keycloak/server.php @@ -11,7 +11,7 @@ declare(strict_types=1); -require_once dirname(__DIR__, 3).'/vendor/autoload.php'; +require_once dirname(__DIR__).'/bootstrap.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; @@ -21,30 +21,20 @@ use Mcp\Server\Transport\Middleware\JwtTokenValidator; use Mcp\Server\Transport\Middleware\ProtectedResourceMetadata; use Mcp\Server\Transport\StreamableHttpTransport; -use Psr\Log\AbstractLogger; -// Configuration from environment +// Configuration // External URL is what clients use and what appears in tokens -$keycloakExternalUrl = getenv('KEYCLOAK_EXTERNAL_URL') ?: 'http://localhost:8180'; +$keycloakExternalUrl = 'http://localhost:8180'; // Internal URL is how this server reaches Keycloak (Docker network) -$keycloakInternalUrl = getenv('KEYCLOAK_INTERNAL_URL') ?: 'http://keycloak:8080'; -$keycloakRealm = getenv('KEYCLOAK_REALM') ?: 'mcp'; -$mcpAudience = getenv('MCP_AUDIENCE') ?: 'mcp-server'; +$keycloakInternalUrl = 'http://keycloak:8080'; +$keycloakRealm = 'mcp'; +$mcpAudience = 'mcp-server'; // Issuer is what appears in the token (external URL) $issuer = rtrim($keycloakExternalUrl, '/').'/realms/'.$keycloakRealm; // JWKS URI uses internal URL to reach Keycloak within Docker network $jwksUri = rtrim($keycloakInternalUrl, '/').'/realms/'.$keycloakRealm.'/protocol/openid-connect/certs'; -// Create logger -$logger = new class extends AbstractLogger { - public function log($level, \Stringable|string $message, array $context = []): void - { - $logMessage = sprintf("[%s] %s\n", strtoupper($level), $message); - error_log($logMessage); - } -}; - // Create PSR-17 factory $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); @@ -77,15 +67,15 @@ public function log($level, \Stringable|string $message, array $context = []): v // Build MCP server $server = Server::builder() ->setServerInfo('OAuth Keycloak Example', '1.0.0') - ->setLogger($logger) - ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__) ->build(); // Create transport with authorization middleware $transport = new StreamableHttpTransport( $request, - logger: $logger, + logger: logger(), middlewares: [$authMiddleware], ); diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index da5c5cc7..97ee1140 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -11,7 +11,7 @@ declare(strict_types=1); -require_once dirname(__DIR__, 3).'/vendor/autoload.php'; +require_once dirname(__DIR__).'/bootstrap.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; @@ -23,7 +23,6 @@ use Mcp\Server\Transport\Middleware\ProtectedResourceMetadata; use Mcp\Server\Transport\StreamableHttpTransport; use Psr\Log\AbstractLogger; -use Psr\Log\LoggerInterface; // Configuration from environment $tenantId = getenv('AZURE_TENANT_ID') ?: throw new RuntimeException('AZURE_TENANT_ID environment variable is required'); @@ -36,15 +35,6 @@ $issuerV1 = "https://sts.windows.net/{$tenantId}/"; $issuers = [$issuerV2, $issuerV1]; -// Create logger -$logger = new class extends AbstractLogger { - public function log($level, \Stringable|string $message, array $context = []): void - { - $logMessage = sprintf("[%s] %s\n", strtoupper($level), $message); - error_log($logMessage); - } -}; - // Create PSR-17 factory $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); @@ -90,8 +80,8 @@ public function log($level, \Stringable|string $message, array $context = []): v // Build MCP server $server = Server::builder() ->setServerInfo('OAuth Microsoft Example', '1.0.0') - ->setLogger($logger) - ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__) ->build(); @@ -99,7 +89,7 @@ public function log($level, \Stringable|string $message, array $context = []): v // Middlewares are reversed internally, so put OAuth proxy FIRST to execute FIRST $transport = new StreamableHttpTransport( $request, - logger: $logger, + logger: logger(), middlewares: [$oauthProxyMiddleware, $authMiddleware], ); diff --git a/src/Server/Transport/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Middleware/AuthorizationMiddleware.php index 6bf6319b..dd24c30a 100644 --- a/src/Server/Transport/Middleware/AuthorizationMiddleware.php +++ b/src/Server/Transport/Middleware/AuthorizationMiddleware.php @@ -70,18 +70,15 @@ public function __construct( public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - // Serve metadata at well-known paths if ($this->isMetadataRequest($request)) { return $this->createMetadataResponse(); } - // Extract Authorization header $authorization = $request->getHeaderLine('Authorization'); if ('' === $authorization) { return $this->buildErrorResponse($request, AuthorizationResult::unauthorized()); } - // Parse Bearer token $accessToken = $this->parseBearerToken($authorization); if (null === $accessToken) { return $this->buildErrorResponse( @@ -90,7 +87,6 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface ); } - // Validate the token $result = $this->validator->validate($request, $accessToken); if ($result->isAllowed()) { return $handler->handle($this->applyAttributes($request, $result->getAttributes())); @@ -101,33 +97,19 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface private function createMetadataResponse(): ResponseInterface { - $payload = $this->metadata->toJson(); - return $this->responseFactory ->createResponse(200) ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($payload)); + ->withBody($this->streamFactory->createStream($this->metadata->toJson())); } private function isMetadataRequest(ServerRequestInterface $request): bool { - if (empty($this->metadataPaths)) { - return false; - } - - if ('GET' !== $request->getMethod()) { + if (empty($this->metadataPaths) || 'GET' !== $request->getMethod()) { return false; } - $path = $request->getUri()->getPath(); - - foreach ($this->metadataPaths as $metadataPath) { - if ($path === $metadataPath) { - return true; - } - } - - return false; + return in_array($request->getUri()->getPath(), $this->metadataPaths, true); } private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface diff --git a/src/Server/Transport/Middleware/AuthorizationResult.php b/src/Server/Transport/Middleware/AuthorizationResult.php index 3750b17d..f3955a54 100644 --- a/src/Server/Transport/Middleware/AuthorizationResult.php +++ b/src/Server/Transport/Middleware/AuthorizationResult.php @@ -22,7 +22,7 @@ * * @author Volodymyr Panivko */ -class AuthorizationResult +final class AuthorizationResult { /** * @param list|null $scopes Scopes to include in WWW-Authenticate challenge diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php index 518db598..b557dfa8 100644 --- a/src/Server/Transport/Middleware/JwtTokenValidator.php +++ b/src/Server/Transport/Middleware/JwtTokenValidator.php @@ -11,10 +11,14 @@ namespace Mcp\Server\Transport\Middleware; +use Firebase\JWT\BeforeValidException; +use Firebase\JWT\ExpiredException; use Firebase\JWT\JWK; use Firebase\JWT\JWT; +use Firebase\JWT\SignatureInvalidException; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; +use Mcp\Exception\RuntimeException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ServerRequestInterface; @@ -128,31 +132,16 @@ public function validate(ServerRequestInterface $request, string $accessToken): } return AuthorizationResult::allow($attributes); - } catch (\Firebase\JWT\ExpiredException $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token has expired.' - ); - } catch (\Firebase\JWT\SignatureInvalidException $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token signature verification failed.' - ); - } catch (\Firebase\JWT\BeforeValidException $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token is not yet valid.' - ); + } catch (ExpiredException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } catch (SignatureInvalidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.'); + } catch (BeforeValidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); } catch (\UnexpectedValueException|\DomainException $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token validation failed: ' . $e->getMessage() - ); - } catch (\Throwable $e) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token validation error.' - ); + return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage()); + } catch (\Throwable) { + return AuthorizationResult::unauthorized('invalid_token', 'Token validation error.'); } } @@ -320,10 +309,9 @@ private function validateIssuer(array $claims): bool return false; } - $tokenIssuer = $claims['iss']; $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; - return \in_array($tokenIssuer, $expectedIssuers, true); + return \in_array($claims['iss'], $expectedIssuers, true); } /** @@ -336,24 +324,24 @@ private function fetchJwks(string $jwksUri): array $response = $this->httpClient->sendRequest($request); - if ($response->getStatusCode() >= 400) { - throw new \RuntimeException(sprintf( + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(sprintf( 'Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode() )); } - $body = (string)$response->getBody(); + $body = (string) $response->getBody(); try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new \RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + throw new RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); } if (!\is_array($data) || !isset($data['keys'])) { - throw new \RuntimeException('Invalid JWKS format: missing "keys" array.'); + throw new RuntimeException('Invalid JWKS format: missing "keys" array.'); } /** @var array $data */ diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 2e4f72c2..9f557f37 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -20,7 +20,6 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; diff --git a/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php b/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php new file mode 100644 index 00000000..aaa5aee8 --- /dev/null +++ b/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php @@ -0,0 +1,248 @@ +createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(401, $response->getStatusCode()); + $header = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('Bearer', $header); + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + $header, + ); + $this->assertStringContainsString('scope="mcp:read"', $header); + } + + #[TestDox('malformed Authorization header returns 400 with invalid_request')] + public function testMalformedAuthorizationReturns400(): void + { + $factory = new Psr17Factory(); + $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(); + } + }; + + $middleware = new AuthorizationMiddleware( + $metadata, + $validator, + $factory, + $factory, + ['/.well-known/oauth-protected-resource'], + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Basic abc'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('error="invalid_request"', $response->getHeaderLine('WWW-Authenticate')); + } + + #[TestDox('insufficient scopes return 403 with scope challenge')] + public function testInsufficientScopeReturns403(): void + { + $factory = new Psr17Factory(); + $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + return AuthorizationResult::forbidden('insufficient_scope', 'Need more scopes.', ['mcp:write']); + } + }; + + $middleware = new AuthorizationMiddleware( + $metadata, + $validator, + $factory, + $factory, + ['/.well-known/oauth-protected-resource'], + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Bearer token'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(403, $response->getStatusCode()); + $header = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('error="insufficient_scope"', $header); + $this->assertStringContainsString('scope="mcp:write"', $header); + } + + #[TestDox('metadata endpoint returns protected resource metadata JSON')] + public function testMetadataEndpointReturnsJson(): void + { + $factory = new Psr17Factory(); + $metadata = new ProtectedResourceMetadata( + ['https://auth.example.com'], + ['mcp:read', 'mcp:write'], + ); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(); + } + }; + + $middleware = new AuthorizationMiddleware( + $metadata, + $validator, + $factory, + $factory, + ['/.well-known/oauth-protected-resource'], + ); + + $request = $factory->createServerRequest( + 'GET', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode((string) $response->getBody(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); + } + + #[TestDox('authorized requests reach the handler with attributes applied')] + public function testAllowedRequestPassesAttributes(): void + { + $factory = new Psr17Factory(); + $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(['subject' => 'user-1']); + } + }; + + $middleware = new AuthorizationMiddleware( + $metadata, + $validator, + $factory, + $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Bearer token'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200) + ->withHeader('X-Subject', (string) $request->getAttribute('subject')); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('user-1', $response->getHeaderLine('X-Subject')); + } +} diff --git a/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php new file mode 100644 index 00000000..95cfbcc2 --- /dev/null +++ b/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php @@ -0,0 +1,716 @@ +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))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: $jwksUri, + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'client_id' => 'client-abc', + 'azp' => 'client-abc', + 'scope' => 'mcp:read mcp:write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $result = $validator->validate($request, $token); + + $this->assertTrue($result->isAllowed()); + $attributes = $result->getAttributes(); + + $this->assertArrayHasKey('oauth.claims', $attributes); + $this->assertArrayHasKey('oauth.scopes', $attributes); + $this->assertSame(['mcp:read', 'mcp:write'], $attributes['oauth.scopes']); + $this->assertSame('user-123', $attributes['oauth.subject']); + $this->assertSame('client-abc', $attributes['oauth.client_id']); + $this->assertSame('client-abc', $attributes['oauth.authorized_party']); + } + + #[TestDox('issuer mismatch yields unauthorized result')] + public function testIssuerMismatchIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $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))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: $jwksUri, + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://other-issuer.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $result = $validator->validate($request, $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token issuer mismatch.', $result->getErrorDescription()); + } + + #[TestDox('audience mismatch yields unauthorized result')] + public function testAudienceMismatchIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $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))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: ['mcp-api'], + jwksUri: $jwksUri, + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'different-aud', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $result = $validator->validate($request, $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token audience mismatch.', $result->getErrorDescription()); + } + + #[TestDox('Graph token (nonce header) is validated by claims without signature verification')] + public function testGraphTokenWithNonceHeaderIsAllowed(): void + { + $factory = new Psr17Factory(); + + // Build a token with a header containing "nonce" to trigger validateGraphToken(). + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + $payload = $this->b64urlEncode(json_encode([ + 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read files.write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], \JSON_THROW_ON_ERROR)); + + $token = $header . '.' . $payload . '.'; + + $validator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksUri: 'https://unused.example.com/jwks', + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory, + scopeClaim: 'scp', + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $result = $validator->validate($request, $token); + + $this->assertTrue($result->isAllowed()); + $attributes = $result->getAttributes(); + + $this->assertTrue($attributes['oauth.graph_token']); + $this->assertSame(['files.read', 'files.write'], $attributes['oauth.scopes']); + $this->assertSame('user-graph', $attributes['oauth.subject']); + } + + #[TestDox('expired token yields unauthorized invalid_token with expired message')] + public function testExpiredTokenIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 7200, + 'exp' => time() - 10, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token has expired.', $result->getErrorDescription()); + } + + #[TestDox('token with future nbf yields unauthorized invalid_token with not-yet-valid message')] + public function testBeforeValidTokenIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time(), + 'nbf' => time() + 3600, + 'exp' => time() + 7200, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token is not yet valid.', $result->getErrorDescription()); + } + + #[TestDox('signature verification failure yields unauthorized invalid_token with signature message')] + public function testSignatureInvalidIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + // Create a mismatched JWK with the same kid so the key lookup succeeds but signature verification fails. + [, $mismatchedJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + $mismatchedJwk['kid'] = $publicJwk['kid']; + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$mismatchedJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token signature verification failed.', $result->getErrorDescription()); + } + + #[TestDox('JWKS HTTP error results in unauthorized token validation error')] + public function testJwksHttpErrorResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), + requestFactory: $factory, + ); + + // Any token without the Graph nonce will attempt JWKS and fail. + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('Invalid JWKS JSON results in unauthorized token validation error')] + public function testInvalidJwksJsonResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{not-json')), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('JWKS without keys array results in unauthorized token validation error')] + public function testJwksMissingKeysResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['nope' => []], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('requireScopes returns forbidden when any required scope is missing')] + public function testRequireScopesForbiddenWhenMissing(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $this->assertTrue($result->isAllowed()); + + $scoped = $validator->requireScopes($result, ['mcp:read', 'mcp:write']); + $this->assertFalse($scoped->isAllowed()); + $this->assertSame(403, $scoped->getStatusCode()); + $this->assertSame('insufficient_scope', $scoped->getError()); + $this->assertSame(['mcp:read', 'mcp:write'], $scoped->getScopes()); + } + + #[TestDox('requireScopes passes through when all required scopes are present')] + public function testRequireScopesPassesWhenPresent(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => ['mcp:read', 'mcp:write'], + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $this->assertTrue($result->isAllowed()); + + $scoped = $validator->requireScopes($result, ['mcp:read']); + $this->assertTrue($scoped->isAllowed()); + } + + #[TestDox('Graph token invalid format is unauthorized')] + public function testGraphTokenInvalidFormatIsUnauthorized(): void + { + $factory = new Psr17Factory(); + + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + // Trigger the Graph token path (nonce in header) with an empty payload segment. + // This makes validateGraphToken() run and fail decoding the payload. + $token = $header . '..'; + + $validator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksUri: 'https://unused.example.com/jwks', + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory, + scopeClaim: 'scp', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token payload.', $result->getErrorDescription()); + } + + #[TestDox('Graph token invalid issuer is unauthorized with graph issuer message')] + public function testGraphTokenInvalidIssuerIsUnauthorized(): void + { + $factory = new Psr17Factory(); + + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + $payload = $this->b64urlEncode(json_encode([ + 'iss' => 'https://evil.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], \JSON_THROW_ON_ERROR)); + + $token = $header . '.' . $payload . '.'; + + $validator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksUri: 'https://unused.example.com/jwks', + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory, + scopeClaim: 'scp', + ); + + $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription()); + } + + #[TestDox('extractScopes returns empty array when scope claim is missing or invalid type')] + public function testExtractScopesEdgeCases(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksResponse = $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))); + + $httpClient = $this->createHttpClientMock([$jwksResponse]); + + // missing scope + $validatorMissing = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient, + requestFactory: $factory, + ); + + $tokenMissing = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $resultMissing = $validatorMissing->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $tokenMissing); + $this->assertTrue($resultMissing->isAllowed()); + $this->assertSame([], $resultMissing->getAttributes()['oauth.scopes']); + + // invalid scope type + $httpClient2 = $this->createHttpClientMock([$jwksResponse]); + + $validatorInvalid = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + httpClient: $httpClient2, + requestFactory: $factory, + ); + + $tokenInvalid = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 123, + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $resultInvalid = $validatorInvalid->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $tokenInvalid); + $this->assertTrue($resultInvalid->isAllowed()); + $this->assertSame([], $resultInvalid->getAttributes()['oauth.scopes']); + } + + private function unsignedJwt(array $claims): string + { + $header = $this->b64urlEncode(json_encode(['alg' => 'none', 'typ' => 'JWT'], \JSON_THROW_ON_ERROR)); + $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); + + return $header . '.' . $payload . '.'; + } + + /** + * @return array{0: string, 1: array} + */ + private function generateRsaKeypairAsJwk(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + + if (false === $key) { + self::fail('Failed to generate RSA keypair via OpenSSL.'); + } + + $privateKeyPem = ''; + if (!openssl_pkey_export($key, $privateKeyPem)) { + self::fail('Failed to export RSA private key.'); + } + + $details = openssl_pkey_get_details($key); + if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { + self::fail('Failed to read RSA key details.'); + } + + $n = $this->b64urlEncode($details['rsa']['n']); + $e = $this->b64urlEncode($details['rsa']['e']); + + $publicJwk = [ + 'kty' => 'RSA', + 'kid' => $kid, + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $n, + 'e' => $e, + ]; + + return [$privateKeyPem, $publicJwk]; + } + + private function b64urlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * @param list $responses + */ + private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface + { + $expectedCalls ??= count($responses); + + $httpClient = $this->createMock(ClientInterface::class); + $expectation = $httpClient + ->expects($this->exactly($expectedCalls)) + ->method('sendRequest') + ->with($this->isInstanceOf(RequestInterface::class)); + + if (1 === $expectedCalls) { + $expectation->willReturn($responses[0]); + } else { + // If expectedCalls > count(responses), keep returning the last response. + $sequence = $responses; + while (count($sequence) < $expectedCalls) { + $sequence[] = $responses[array_key_last($responses)]; + } + $expectation->willReturnOnConsecutiveCalls(...$sequence); + } + + return $httpClient; + } +} From c61a7562eadeb55a7ac64ea1acc3361647e3093f Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Sun, 15 Feb 2026 20:22:00 +0100 Subject: [PATCH 07/18] Address PR review feedback for OAuth middleware --- composer.json | 1 - examples/server/oauth-keycloak/README.md | 15 ++++--- .../Middleware/AuthorizationMiddleware.php | 40 +++++-------------- .../Middleware/JwtTokenValidator.php | 2 +- .../Middleware/OAuthProxyMiddleware.php | 6 +-- .../Transport/Middleware/OidcDiscovery.php | 2 +- .../AuthorizationMiddlewareTest.php | 2 +- 7 files changed, 23 insertions(+), 45 deletions(-) diff --git a/composer.json b/composer.json index da4a29e3..34dde03b 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,6 @@ }, "require-dev": { "firebase/php-jwt": "^6.10", - "guzzlehttp/guzzle": "^7.0", "laminas/laminas-httphandlerrunner": "^2.12", "nyholm/psr7": "^1.8", "nyholm/psr7-server": "^1.1", diff --git a/examples/server/oauth-keycloak/README.md b/examples/server/oauth-keycloak/README.md index fb3029be..d1929318 100644 --- a/examples/server/oauth-keycloak/README.md +++ b/examples/server/oauth-keycloak/README.md @@ -57,7 +57,7 @@ curl -X POST http://localhost:8000/mcp \ 5. **Use with MCP Inspector:** -The MCP Inspector doesn't support OAuth out of the box, but you can test using curl or build a custom client. +MCP Inspector can call this server if you provide a valid Bearer token manually (Authorization header). It does not run the OAuth login flow automatically. ## Keycloak Configuration @@ -102,14 +102,13 @@ Access at http://localhost:8180/admin with: - `server.php` - MCP server with OAuth middleware - `McpElements.php` - MCP tools and resources -## Environment Variables +## Configuration -| Variable | Default | Description | -|----------|---------|-------------| -| `KEYCLOAK_EXTERNAL_URL` | `http://localhost:8180` | Keycloak URL as seen by clients (token issuer) | -| `KEYCLOAK_INTERNAL_URL` | `http://keycloak:8080` | Keycloak URL from within Docker network (for JWKS) | -| `KEYCLOAK_REALM` | `mcp` | Keycloak realm name | -| `MCP_AUDIENCE` | `mcp-server` | Expected JWT audience | +This example uses hard-coded values in `server.php` for consistency with other examples: +- Keycloak external URL: `http://localhost:8180` +- Keycloak internal URL: `http://keycloak:8080` +- Realm: `mcp` +- Audience: `mcp-server` ## Troubleshooting diff --git a/src/Server/Transport/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Middleware/AuthorizationMiddleware.php index dd24c30a..4b48b262 100644 --- a/src/Server/Transport/Middleware/AuthorizationMiddleware.php +++ b/src/Server/Transport/Middleware/AuthorizationMiddleware.php @@ -88,11 +88,11 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } $result = $this->validator->validate($request, $accessToken); - if ($result->isAllowed()) { - return $handler->handle($this->applyAttributes($request, $result->getAttributes())); + if (!$result->isAllowed()) { + return $this->buildErrorResponse($request, $result); } - return $this->buildErrorResponse($request, $result); + return $handler->handle($this->applyAttributes($request, $result->getAttributes())); } private function createMetadataResponse(): ResponseInterface @@ -105,7 +105,7 @@ private function createMetadataResponse(): ResponseInterface private function isMetadataRequest(ServerRequestInterface $request): bool { - if (empty($this->metadataPaths) || 'GET' !== $request->getMethod()) { + if ([] === $this->metadataPaths || 'GET' !== $request->getMethod()) { return false; } @@ -128,19 +128,16 @@ private function buildAuthenticateHeader(ServerRequestInterface $request, Author { $parts = []; - // Include resource_metadata URL per RFC 9728 $resourceMetadataUrl = $this->resolveResourceMetadataUrl($request); if (null !== $resourceMetadataUrl) { $parts[] = 'resource_metadata="' . $this->escapeHeaderValue($resourceMetadataUrl) . '"'; } - // Include scope hint per RFC 6750 Section 3 $scopes = $this->resolveScopes($request, $result); - if (!empty($scopes)) { + if (null !== $scopes) { $parts[] = 'scope="' . $this->escapeHeaderValue(implode(' ', $scopes)) . '"'; } - // Include error details if (null !== $result->getError()) { $parts[] = 'error="' . $this->escapeHeaderValue($result->getError()) . '"'; } @@ -149,7 +146,7 @@ private function buildAuthenticateHeader(ServerRequestInterface $request, Author $parts[] = 'error_description="' . $this->escapeHeaderValue($result->getErrorDescription()) . '"'; } - if (empty($parts)) { + if ([] === $parts) { return 'Bearer'; } @@ -161,13 +158,11 @@ private function buildAuthenticateHeader(ServerRequestInterface $request, Author */ private function resolveScopes(ServerRequestInterface $request, AuthorizationResult $result): ?array { - // First, check if the result has specific scopes (e.g., from insufficient_scope error) $scopes = $this->normalizeScopes($result->getScopes()); if (null !== $scopes) { return $scopes; } - // Then, check the scope provider callback if (null !== $this->scopeProvider) { $provided = ($this->scopeProvider)($request); $scopes = $this->normalizeScopes($provided); @@ -176,7 +171,6 @@ private function resolveScopes(ServerRequestInterface $request, AuthorizationRes } } - // Fall back to scopes from metadata return $this->normalizeScopes($this->metadata->getScopesSupported()); } @@ -195,44 +189,30 @@ private function normalizeScopes(?array $scopes): ?array return '' !== $scope; })); - return empty($normalized) ? null : $normalized; + return [] === $normalized ? null : $normalized; } private function resolveResourceMetadataUrl(ServerRequestInterface $request): ?string { - // Use explicit URL if configured if (null !== $this->resourceMetadataUrl) { return $this->resourceMetadataUrl; } - // Auto-generate from request if metadata paths are configured - if (empty($this->metadataPaths)) { + if ([] === $this->metadataPaths) { return null; } $uri = $request->getUri(); $scheme = $uri->getScheme(); - $host = $uri->getHost(); + $authority = $uri->getAuthority(); - if ('' === $scheme || '' === $host) { + if ('' === $scheme || '' === $authority) { return null; } - $authority = $host; - $port = $uri->getPort(); - - if (null !== $port && !$this->isDefaultPort($scheme, $port)) { - $authority .= ':' . $port; - } - return $scheme . '://' . $authority . $this->metadataPaths[0]; } - private function isDefaultPort(string $scheme, int $port): bool - { - return ('https' === $scheme && 443 === $port) || ('http' === $scheme && 80 === $port); - } - /** * @param array $attributes */ diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php index b557dfa8..076af516 100644 --- a/src/Server/Transport/Middleware/JwtTokenValidator.php +++ b/src/Server/Transport/Middleware/JwtTokenValidator.php @@ -332,7 +332,7 @@ private function fetchJwks(string $jwksUri): array )); } - $body = (string) $response->getBody(); + $body = $response->getBody()->__toString(); try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); diff --git a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php index a0a5e5f1..0f5c94c6 100644 --- a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php @@ -119,7 +119,7 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface } // Get the request body and parse it - $body = (string)$request->getBody(); + $body = $request->getBody()->__toString(); parse_str($body, $params); // Inject client_secret if configured and not already present @@ -143,7 +143,7 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface try { $upstreamResponse = $this->httpClient->sendRequest($upstreamRequest); - $responseBody = (string)$upstreamResponse->getBody(); + $responseBody = $upstreamResponse->getBody()->__toString(); // Return upstream response as-is return $this->responseFactory @@ -208,7 +208,7 @@ private function getUpstreamMetadata(): array $response = $this->httpClient->sendRequest($request); if (200 === $response->getStatusCode()) { - $this->upstreamMetadata = json_decode((string)$response->getBody(), true) ?? []; + $this->upstreamMetadata = json_decode($response->getBody()->__toString(), true) ?? []; return $this->upstreamMetadata; } diff --git a/src/Server/Transport/Middleware/OidcDiscovery.php b/src/Server/Transport/Middleware/OidcDiscovery.php index 5124c83c..4721c24b 100644 --- a/src/Server/Transport/Middleware/OidcDiscovery.php +++ b/src/Server/Transport/Middleware/OidcDiscovery.php @@ -274,7 +274,7 @@ private function fetchJson(string $url): array )); } - $body = (string)$response->getBody(); + $body = $response->getBody()->__toString(); try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); diff --git a/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php b/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php index aaa5aee8..d13239b1 100644 --- a/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php @@ -201,7 +201,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->assertSame(200, $response->getStatusCode()); $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - $payload = json_decode((string) $response->getBody(), true, 512, \JSON_THROW_ON_ERROR); + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); } From 0add43d9c04561a2f1297db9f3a8c480d5e4754d Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Sun, 15 Feb 2026 20:35:40 +0100 Subject: [PATCH 08/18] Apply php-cs-fixer to satisfy QA --- .../server/oauth-keycloak/McpElements.php | 17 +++---- examples/server/oauth-keycloak/server.php | 5 +- .../server/oauth-microsoft/McpElements.php | 9 ++-- examples/server/oauth-microsoft/server.php | 6 +-- .../Middleware/AuthorizationMiddleware.php | 30 ++++++------ .../Middleware/AuthorizationResult.php | 25 +++++----- .../AuthorizationTokenValidatorInterface.php | 4 +- .../Middleware/JwtTokenValidator.php | 46 +++++++++---------- .../Middleware/OAuthProxyMiddleware.php | 18 ++++---- .../Transport/Middleware/OidcDiscovery.php | 42 +++++++---------- .../Middleware/ProtectedResourceMetadata.php | 8 ++-- .../Middleware/JwtTokenValidatorTest.php | 20 ++++---- 12 files changed, 109 insertions(+), 121 deletions(-) diff --git a/examples/server/oauth-keycloak/McpElements.php b/examples/server/oauth-keycloak/McpElements.php index d2b07562..e47dfe58 100644 --- a/examples/server/oauth-keycloak/McpElements.php +++ b/examples/server/oauth-keycloak/McpElements.php @@ -1,5 +1,8 @@ 'success', - 'message' => sprintf('Simulated %s request to %s', $method, $endpoint), + 'message' => \sprintf('Simulated %s request to %s', $method, $endpoint), 'simulated_response' => [ 'data' => 'This is simulated data from the protected API', 'timestamp' => date('c'), @@ -84,7 +85,7 @@ public function getServerStatus(): array return [ 'status' => 'healthy', 'timestamp' => date('c'), - 'php_version' => PHP_VERSION, + 'php_version' => \PHP_VERSION, 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), 'protected' => true, ]; @@ -100,10 +101,10 @@ public function getServerStatus(): array public function greeting(string $style = 'formal'): string { return match ($style) { - 'casual' => "Hey there! Welcome to the protected MCP server!", - 'formal' => "Good day. Welcome to the OAuth-protected MCP server.", - 'friendly' => "Hello! Great to have you here!", - default => "Welcome to the MCP server!", + 'casual' => 'Hey there! Welcome to the protected MCP server!', + 'formal' => 'Good day. Welcome to the OAuth-protected MCP server.', + 'friendly' => 'Hello! Great to have you here!', + default => 'Welcome to the MCP server!', }; } } diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php index c8a7e667..42e4bb4b 100644 --- a/examples/server/oauth-keycloak/server.php +++ b/examples/server/oauth-keycloak/server.php @@ -1,5 +1,8 @@ 'Simulated data. Implement Graph API call with Mail.Read scope for real emails.', - 'emails' => array_map(fn ($i) => [ + 'emails' => array_map(static fn ($i) => [ 'id' => 'msg_'.uniqid(), 'subject' => "Sample Email #{$i}", 'from' => "sender{$i}@example.com", @@ -102,7 +103,7 @@ public function getServerStatus(): array 'status' => 'healthy', 'timestamp' => date('c'), 'auth_provider' => 'Microsoft Entra ID', - 'php_version' => PHP_VERSION, + 'php_version' => \PHP_VERSION, 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), ]; } diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index 97ee1140..8e03379b 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -1,5 +1,8 @@ $metadataPaths Paths where metadata should be served (e.g., ["/.well-known/oauth-protected-resource"]) - * @param string|null $resourceMetadataUrl Explicit URL for the resource_metadata in WWW-Authenticate - * @param callable(ServerRequestInterface): list|null $scopeProvider Optional callback to determine required scopes per request + * @param ProtectedResourceMetadata $metadata The protected resource metadata to serve + * @param AuthorizationTokenValidatorInterface $validator Token validator implementation + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * @param list $metadataPaths Paths where metadata should be served (e.g., ["/.well-known/oauth-protected-resource"]) + * @param string|null $resourceMetadataUrl Explicit URL for the resource_metadata in WWW-Authenticate + * @param callable(ServerRequestInterface): list|null $scopeProvider Optional callback to determine required scopes per request */ public function __construct( private ProtectedResourceMetadata $metadata, @@ -109,7 +109,7 @@ private function isMetadataRequest(ServerRequestInterface $request): bool return false; } - return in_array($request->getUri()->getPath(), $this->metadataPaths, true); + return \in_array($request->getUri()->getPath(), $this->metadataPaths, true); } private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface @@ -130,27 +130,27 @@ private function buildAuthenticateHeader(ServerRequestInterface $request, Author $resourceMetadataUrl = $this->resolveResourceMetadataUrl($request); if (null !== $resourceMetadataUrl) { - $parts[] = 'resource_metadata="' . $this->escapeHeaderValue($resourceMetadataUrl) . '"'; + $parts[] = 'resource_metadata="'.$this->escapeHeaderValue($resourceMetadataUrl).'"'; } $scopes = $this->resolveScopes($request, $result); if (null !== $scopes) { - $parts[] = 'scope="' . $this->escapeHeaderValue(implode(' ', $scopes)) . '"'; + $parts[] = 'scope="'.$this->escapeHeaderValue(implode(' ', $scopes)).'"'; } if (null !== $result->getError()) { - $parts[] = 'error="' . $this->escapeHeaderValue($result->getError()) . '"'; + $parts[] = 'error="'.$this->escapeHeaderValue($result->getError()).'"'; } if (null !== $result->getErrorDescription()) { - $parts[] = 'error_description="' . $this->escapeHeaderValue($result->getErrorDescription()) . '"'; + $parts[] = 'error_description="'.$this->escapeHeaderValue($result->getErrorDescription()).'"'; } if ([] === $parts) { return 'Bearer'; } - return 'Bearer ' . implode(', ', $parts); + return 'Bearer '.implode(', ', $parts); } /** @@ -210,7 +210,7 @@ private function resolveResourceMetadataUrl(ServerRequestInterface $request): ?s return null; } - return $scheme . '://' . $authority . $this->metadataPaths[0]; + return $scheme.'://'.$authority.$this->metadataPaths[0]; } /** @@ -240,7 +240,7 @@ private function normalizePaths(array $paths): array continue; } if ('/' !== $path[0]) { - $path = '/' . $path; + $path = '/'.$path; } $normalized[] = $path; } diff --git a/src/Server/Transport/Middleware/AuthorizationResult.php b/src/Server/Transport/Middleware/AuthorizationResult.php index f3955a54..c1600bc4 100644 --- a/src/Server/Transport/Middleware/AuthorizationResult.php +++ b/src/Server/Transport/Middleware/AuthorizationResult.php @@ -25,7 +25,7 @@ final class AuthorizationResult { /** - * @param list|null $scopes Scopes to include in WWW-Authenticate challenge + * @param list|null $scopes Scopes to include in WWW-Authenticate challenge * @param array $attributes Attributes to attach to the request on success */ private function __construct( @@ -53,16 +53,15 @@ public static function allow(array $attributes = []): self * * Use when no valid credentials are provided or the token is invalid. * - * @param string|null $error OAuth error code (e.g., "invalid_token") - * @param string|null $errorDescription Human-readable error description - * @param list|null $scopes Required scopes to include in challenge + * @param string|null $error OAuth error code (e.g., "invalid_token") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge */ public static function unauthorized( ?string $error = null, ?string $errorDescription = null, ?array $scopes = null, - ): self - { + ): self { return new self(false, 401, $error, $errorDescription, $scopes, []); } @@ -71,16 +70,15 @@ public static function unauthorized( * * Use when the token is valid but lacks required permissions/scopes. * - * @param string|null $error OAuth error code (defaults to "insufficient_scope") - * @param string|null $errorDescription Human-readable error description - * @param list|null $scopes Required scopes to include in challenge + * @param string|null $error OAuth error code (defaults to "insufficient_scope") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge */ public static function forbidden( ?string $error = 'insufficient_scope', ?string $errorDescription = null, ?array $scopes = null, - ): self - { + ): self { return new self(false, 403, $error ?? 'insufficient_scope', $errorDescription, $scopes, []); } @@ -89,14 +87,13 @@ public static function forbidden( * * Use when the Authorization header is malformed. * - * @param string|null $error OAuth error code (defaults to "invalid_request") + * @param string|null $error OAuth error code (defaults to "invalid_request") * @param string|null $errorDescription Human-readable error description */ public static function badRequest( ?string $error = 'invalid_request', ?string $errorDescription = null, - ): self - { + ): self { return new self(false, 400, $error ?? 'invalid_request', $errorDescription, null, []); } diff --git a/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php b/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php index 6c315e72..70a2043c 100644 --- a/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php +++ b/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php @@ -26,8 +26,8 @@ interface AuthorizationTokenValidatorInterface /** * Validates an access token extracted from the Authorization header. * - * @param ServerRequestInterface $request The incoming HTTP request - * @param string $accessToken The bearer token (without "Bearer " prefix) + * @param ServerRequestInterface $request The incoming HTTP request + * @param string $accessToken The bearer token (without "Bearer " prefix) * * @return AuthorizationResult The result of the validation */ diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php index 076af516..9f81be87 100644 --- a/src/Server/Transport/Middleware/JwtTokenValidator.php +++ b/src/Server/Transport/Middleware/JwtTokenValidator.php @@ -46,15 +46,15 @@ class JwtTokenValidator implements AuthorizationTokenValidatorInterface private const CACHE_KEY_PREFIX = 'mcp_jwt_jwks_'; /** - * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") For Microsoft Entra ID, you may need to provide both v1.0 and v2.0 issuers - * @param string|list $audience Expected audience(s) for the token - * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") For Microsoft Entra ID, you may need to provide both v1.0 and v2.0 issuers + * @param string|list $audience Expected audience(s) for the token + * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param CacheInterface|null $cache PSR-16 cache for JWKS (optional) - * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) - * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) - * @param string $scopeClaim Claim name for scopes (default: "scope") + * @param CacheInterface|null $cache PSR-16 cache for JWKS (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) + * @param string $scopeClaim Claim name for scopes (default: "scope") */ public function __construct( private readonly string|array $issuer, @@ -76,7 +76,7 @@ public function validate(ServerRequestInterface $request, string $accessToken): // Decode header to see key ID $parts = explode('.', $accessToken); $header = null; - if (count($parts) >= 2) { + if (\count($parts) >= 2) { $header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true); } @@ -90,7 +90,7 @@ public function validate(ServerRequestInterface $request, string $accessToken): $keys = $this->getJwks(); $decoded = JWT::decode($accessToken, $keys); /** @var array $claims */ - $claims = (array)$decoded; + $claims = (array) $decoded; // Validate issuer if (!$this->validateIssuer($claims)) { @@ -154,12 +154,12 @@ public function validate(ServerRequestInterface $request, string $accessToken): * * This method performs claim-based validation without signature verification. * - * @param string $accessToken The JWT access token - * @param array $parts Token parts (header, payload, signature) + * @param string $accessToken The JWT access token + * @param array $parts Token parts (header, payload, signature) */ private function validateGraphToken(string $accessToken, array $parts): AuthorizationResult { - if (count($parts) < 2) { + if (\count($parts) < 2) { return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); } @@ -219,8 +219,8 @@ private function validateGraphToken(string $accessToken, array $parts): Authoriz * * Use this after validation to check specific scope requirements. * - * @param AuthorizationResult $result The result from validate() - * @param list $requiredScopes Scopes required for this operation + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation * * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise */ @@ -240,7 +240,7 @@ public function requireScopes(AuthorizationResult $result, array $requiredScopes if (!\in_array($required, $tokenScopes, true)) { return AuthorizationResult::forbidden( 'insufficient_scope', - sprintf('Required scope: %s', $required), + \sprintf('Required scope: %s', $required), $requiredScopes ); } @@ -255,7 +255,7 @@ public function requireScopes(AuthorizationResult $result, array $requiredScopes private function getJwks(): array { $jwksUri = $this->resolveJwksUri(); - $cacheKey = self::CACHE_KEY_PREFIX . hash('sha256', $jwksUri); + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri); $jwksData = null; @@ -275,7 +275,7 @@ private function getJwks(): array } } - /** @var array */ + /* @var array */ return JWK::parseKeySet($jwksData, $this->algorithms[0]); } @@ -325,11 +325,7 @@ private function fetchJwks(string $jwksUri): array $response = $this->httpClient->sendRequest($request); if (200 !== $response->getStatusCode()) { - throw new RuntimeException(sprintf( - 'Failed to fetch JWKS from %s: HTTP %d', - $jwksUri, - $response->getStatusCode() - )); + throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode())); } $body = $response->getBody()->__toString(); @@ -337,14 +333,14 @@ private function fetchJwks(string $jwksUri): array try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); } if (!\is_array($data) || !isset($data['keys'])) { throw new RuntimeException('Invalid JWKS format: missing "keys" array.'); } - /** @var array $data */ + /* @var array $data */ return $data; } diff --git a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php index 0f5c94c6..1489c073 100644 --- a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php @@ -45,9 +45,9 @@ final class OAuthProxyMiddleware implements MiddlewareInterface private ?array $upstreamMetadata = null; /** - * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider - * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) - * @param string|null $clientSecret Optional client secret for confidential clients + * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider + * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) + * @param string|null $clientSecret Optional client secret for confidential clients */ public function __construct( private readonly string $upstreamIssuer, @@ -100,7 +100,7 @@ private function handleAuthorize(ServerRequestInterface $request): ResponseInter $rawQueryString = $request->getUri()->getQuery(); // Build upstream URL preserving exact query string - $upstreamUrl = $authorizationEndpoint . '?' . $rawQueryString; + $upstreamUrl = $authorizationEndpoint.'?'.$rawQueryString; // Redirect to upstream authorization server return $this->responseFactory @@ -152,7 +152,7 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface ->withHeader('Cache-Control', 'no-store') ->withBody($this->streamFactory->createStream($responseBody)); } catch (\Throwable $e) { - return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: ' . $e->getMessage()); + return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: '.$e->getMessage()); } } @@ -163,8 +163,8 @@ private function createAuthServerMetadataResponse(): ResponseInterface // Create local metadata that points to our proxy endpoints $localMetadata = [ 'issuer' => $this->upstreamIssuer, - 'authorization_endpoint' => rtrim($this->localBaseUrl, '/') . '/authorize', - 'token_endpoint' => rtrim($this->localBaseUrl, '/') . '/token', + 'authorization_endpoint' => rtrim($this->localBaseUrl, '/').'/authorize', + 'token_endpoint' => rtrim($this->localBaseUrl, '/').'/token', 'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'], 'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'], 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], @@ -198,8 +198,8 @@ private function getUpstreamMetadata(): array // Try OpenID Connect discovery first $discoveryUrls = [ - rtrim($this->upstreamIssuer, '/') . '/.well-known/openid-configuration', - rtrim($this->upstreamIssuer, '/') . '/.well-known/oauth-authorization-server', + rtrim($this->upstreamIssuer, '/').'/.well-known/openid-configuration', + rtrim($this->upstreamIssuer, '/').'/.well-known/oauth-authorization-server', ]; foreach ($discoveryUrls as $url) { diff --git a/src/Server/Transport/Middleware/OidcDiscovery.php b/src/Server/Transport/Middleware/OidcDiscovery.php index 4721c24b..fa6f484e 100644 --- a/src/Server/Transport/Middleware/OidcDiscovery.php +++ b/src/Server/Transport/Middleware/OidcDiscovery.php @@ -37,10 +37,10 @@ class OidcDiscovery private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; /** - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) - * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) */ public function __construct( ?ClientInterface $httpClient = null, @@ -68,7 +68,7 @@ public function __construct( */ public function discover(string $issuer): array { - $cacheKey = self::CACHE_KEY_PREFIX . hash('sha256', $issuer); + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $issuer); if (null !== $this->cache) { $cached = $this->cache->get($cacheKey); @@ -119,7 +119,7 @@ public function fetchJwks(string $issuer): array { $jwksUri = $this->getJwksUri($issuer); - $cacheKey = self::CACHE_KEY_PREFIX . 'jwks_' . hash('sha256', $jwksUri); + $cacheKey = self::CACHE_KEY_PREFIX.'jwks_'.hash('sha256', $jwksUri); if (null !== $this->cache) { $cached = $this->cache->get($cacheKey); @@ -204,15 +204,15 @@ private function fetchMetadata(string $issuer): array $parsed = parse_url($issuer); if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { - throw new \RuntimeException(sprintf('Invalid issuer URL: %s', $issuer)); + throw new \RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); } $scheme = $parsed['scheme']; $host = $parsed['host']; - $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + $port = isset($parsed['port']) ? ':'.$parsed['port'] : ''; $path = $parsed['path'] ?? ''; - $baseUrl = $scheme . '://' . $host . $port; + $baseUrl = $scheme.'://'.$host.$port; // Build discovery URLs in priority order per RFC 8414 Section 3.1 $discoveryUrls = []; @@ -220,15 +220,15 @@ private function fetchMetadata(string $issuer): array if ('' !== $path && '/' !== $path) { // For issuer URLs with path components // 1. OAuth 2.0 path insertion - $discoveryUrls[] = $baseUrl . '/.well-known/oauth-authorization-server' . $path; + $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'.$path; // 2. OIDC path insertion - $discoveryUrls[] = $baseUrl . '/.well-known/openid-configuration' . $path; + $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; // 3. OIDC path appending - $discoveryUrls[] = $issuer . '/.well-known/openid-configuration'; + $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; } else { // For issuer URLs without path components - $discoveryUrls[] = $baseUrl . '/.well-known/oauth-authorization-server'; - $discoveryUrls[] = $baseUrl . '/.well-known/openid-configuration'; + $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; + $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'; } $lastException = null; @@ -249,11 +249,7 @@ private function fetchMetadata(string $issuer): array } } - throw new \RuntimeException( - sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), - 0, - $lastException - ); + throw new \RuntimeException(\sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), 0, $lastException); } /** @@ -267,11 +263,7 @@ private function fetchJson(string $url): array $response = $this->httpClient->sendRequest($request); if ($response->getStatusCode() >= 400) { - throw new \RuntimeException(sprintf( - 'HTTP request to %s failed with status %d', - $url, - $response->getStatusCode() - )); + throw new \RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); } $body = $response->getBody()->__toString(); @@ -279,11 +271,11 @@ private function fetchJson(string $url): array try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new \RuntimeException(sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); + throw new \RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); } if (!\is_array($data)) { - throw new \RuntimeException(sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); + throw new \RuntimeException(\sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); } return $data; diff --git a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php index 067efa05..87207a84 100644 --- a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php +++ b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php @@ -24,10 +24,10 @@ class ProtectedResourceMetadata { /** - * @param list $authorizationServers URLs of authorization servers that can issue tokens for this resource - * @param list|null $scopesSupported OAuth scopes supported by this resource - * @param string|null $resource The resource identifier (typically the resource's URL) - * @param array $extra Additional metadata fields + * @param list $authorizationServers URLs of authorization servers that can issue tokens for this resource + * @param list|null $scopesSupported OAuth scopes supported by this resource + * @param string|null $resource The resource identifier (typically the resource's URL) + * @param array $extra Additional metadata fields */ public function __construct( private readonly array $authorizationServers, diff --git a/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php index 95cfbcc2..0e62a6c2 100644 --- a/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php +++ b/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php @@ -182,7 +182,7 @@ public function testGraphTokenWithNonceHeaderIsAllowed(): void 'exp' => time() + 600, ], \JSON_THROW_ON_ERROR)); - $token = $header . '.' . $payload . '.'; + $token = $header.'.'.$payload.'.'; $validator = new JwtTokenValidator( issuer: ['https://auth.example.com'], @@ -509,7 +509,7 @@ public function testGraphTokenInvalidFormatIsUnauthorized(): void // Trigger the Graph token path (nonce in header) with an empty payload segment. // This makes validateGraphToken() run and fail decoding the payload. - $token = $header . '..'; + $token = $header.'..'; $validator = new JwtTokenValidator( issuer: ['https://auth.example.com'], @@ -548,7 +548,7 @@ public function testGraphTokenInvalidIssuerIsUnauthorized(): void 'exp' => time() + 600, ], \JSON_THROW_ON_ERROR)); - $token = $header . '.' . $payload . '.'; + $token = $header.'.'.$payload.'.'; $validator = new JwtTokenValidator( issuer: ['https://auth.example.com'], @@ -640,7 +640,7 @@ private function unsignedJwt(array $claims): string $header = $this->b64urlEncode(json_encode(['alg' => 'none', 'typ' => 'JWT'], \JSON_THROW_ON_ERROR)); $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); - return $header . '.' . $payload . '.'; + return $header.'.'.$payload.'.'; } /** @@ -649,22 +649,22 @@ private function unsignedJwt(array $claims): string private function generateRsaKeypairAsJwk(string $kid): array { $key = openssl_pkey_new([ - 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048, ]); if (false === $key) { - self::fail('Failed to generate RSA keypair via OpenSSL.'); + $this->fail('Failed to generate RSA keypair via OpenSSL.'); } $privateKeyPem = ''; if (!openssl_pkey_export($key, $privateKeyPem)) { - self::fail('Failed to export RSA private key.'); + $this->fail('Failed to export RSA private key.'); } $details = openssl_pkey_get_details($key); if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { - self::fail('Failed to read RSA key details.'); + $this->fail('Failed to read RSA key details.'); } $n = $this->b64urlEncode($details['rsa']['n']); @@ -692,7 +692,7 @@ private function b64urlEncode(string $data): string */ private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface { - $expectedCalls ??= count($responses); + $expectedCalls ??= \count($responses); $httpClient = $this->createMock(ClientInterface::class); $expectation = $httpClient @@ -705,7 +705,7 @@ private function createHttpClientMock(array $responses, ?int $expectedCalls = nu } else { // If expectedCalls > count(responses), keep returning the last response. $sequence = $responses; - while (count($sequence) < $expectedCalls) { + while (\count($sequence) < $expectedCalls) { $sequence[] = $responses[array_key_last($responses)]; } $expectation->willReturnOnConsecutiveCalls(...$sequence); From a2dcce7f71e1b6b0e896721e01728512a9c4b15a Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Sat, 21 Feb 2026 18:33:35 +0100 Subject: [PATCH 09/18] Use Mcp exception classes in OAuth middleware --- .../Transport/Middleware/OidcDiscovery.php | 29 ++++++++++--------- .../Middleware/ProtectedResourceMetadata.php | 4 ++- .../AuthorizationMiddlewareTest.php | 3 +- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/Server/Transport/Middleware/OidcDiscovery.php b/src/Server/Transport/Middleware/OidcDiscovery.php index fa6f484e..e8186016 100644 --- a/src/Server/Transport/Middleware/OidcDiscovery.php +++ b/src/Server/Transport/Middleware/OidcDiscovery.php @@ -13,6 +13,7 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; +use Mcp\Exception\RuntimeException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\SimpleCache\CacheInterface; @@ -64,7 +65,7 @@ public function __construct( * * @return array The authorization server metadata * - * @throws \RuntimeException If discovery fails + * @throws RuntimeException If discovery fails */ public function discover(string $issuer): array { @@ -93,14 +94,14 @@ public function discover(string $issuer): array * * @return string The JWKS URI * - * @throws \RuntimeException If JWKS URI is not found in metadata + * @throws RuntimeException If JWKS URI is not found in metadata */ public function getJwksUri(string $issuer): string { $metadata = $this->discover($issuer); if (!isset($metadata['jwks_uri']) || !\is_string($metadata['jwks_uri'])) { - throw new \RuntimeException('Authorization server metadata does not contain jwks_uri.'); + throw new RuntimeException('Authorization server metadata does not contain jwks_uri.'); } return $metadata['jwks_uri']; @@ -113,7 +114,7 @@ public function getJwksUri(string $issuer): string * * @return array The JWKS * - * @throws \RuntimeException If fetching fails + * @throws RuntimeException If fetching fails */ public function fetchJwks(string $issuer): array { @@ -162,14 +163,14 @@ public function supportsPkce(string $issuer): bool * * @return string The token endpoint URL * - * @throws \RuntimeException If token endpoint is not found + * @throws RuntimeException If token endpoint is not found */ public function getTokenEndpoint(string $issuer): string { $metadata = $this->discover($issuer); if (!isset($metadata['token_endpoint']) || !\is_string($metadata['token_endpoint'])) { - throw new \RuntimeException('Authorization server metadata does not contain token_endpoint.'); + throw new RuntimeException('Authorization server metadata does not contain token_endpoint.'); } return $metadata['token_endpoint']; @@ -182,14 +183,14 @@ public function getTokenEndpoint(string $issuer): string * * @return string The authorization endpoint URL * - * @throws \RuntimeException If authorization endpoint is not found + * @throws RuntimeException If authorization endpoint is not found */ public function getAuthorizationEndpoint(string $issuer): string { $metadata = $this->discover($issuer); if (!isset($metadata['authorization_endpoint']) || !\is_string($metadata['authorization_endpoint'])) { - throw new \RuntimeException('Authorization server metadata does not contain authorization_endpoint.'); + throw new RuntimeException('Authorization server metadata does not contain authorization_endpoint.'); } return $metadata['authorization_endpoint']; @@ -204,7 +205,7 @@ private function fetchMetadata(string $issuer): array $parsed = parse_url($issuer); if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { - throw new \RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); + throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); } $scheme = $parsed['scheme']; @@ -243,13 +244,13 @@ private function fetchMetadata(string $issuer): array } return $metadata; - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { $lastException = $e; continue; } } - throw new \RuntimeException(\sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), 0, $lastException); + throw new RuntimeException(\sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), 0, $lastException); } /** @@ -263,7 +264,7 @@ private function fetchJson(string $url): array $response = $this->httpClient->sendRequest($request); if ($response->getStatusCode() >= 400) { - throw new \RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); + throw new RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); } $body = $response->getBody()->__toString(); @@ -271,11 +272,11 @@ private function fetchJson(string $url): array try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new \RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); + throw new RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); } if (!\is_array($data)) { - throw new \RuntimeException(\sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); + throw new RuntimeException(\sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); } return $data; diff --git a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php index 87207a84..0665e714 100644 --- a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php +++ b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php @@ -11,6 +11,8 @@ namespace Mcp\Server\Transport\Middleware; +use Mcp\Exception\InvalidArgumentException; + /** * Represents OAuth 2.0 Protected Resource Metadata (RFC 9728). * @@ -36,7 +38,7 @@ public function __construct( private readonly array $extra = [], ) { if (empty($authorizationServers)) { - throw new \InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); + throw new InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); } } diff --git a/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php b/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php index d13239b1..835d6443 100644 --- a/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Server\Transport\Middleware; +use Mcp\Exception\RuntimeException; use Mcp\Server\Transport\Middleware\AuthorizationMiddleware; use Mcp\Server\Transport\Middleware\AuthorizationResult; use Mcp\Server\Transport\Middleware\AuthorizationTokenValidatorInterface; @@ -33,7 +34,7 @@ public function testMissingAuthorizationReturns401(): void $validator = new class implements AuthorizationTokenValidatorInterface { public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult { - throw new \RuntimeException('Validator should not be called without a token.'); + throw new RuntimeException('Validator should not be called without a token.'); } }; From ba5ef521895a6ef8a210a55b31ed58d2dec81ef2 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Sun, 22 Feb 2026 11:25:22 +0100 Subject: [PATCH 10/18] Refactor OAuth HTTP stack and add Microsoft example policies --- .docker/nginx.local.conf | 13 + .docker/php-local.Dockerfile | 11 + composer.json | 7 +- docker-compose.local.yml | 39 ++ examples/server/oauth-keycloak/Dockerfile | 2 +- .../server/oauth-keycloak/McpElements.php | 22 +- .../server/oauth-keycloak/docker-compose.yml | 9 +- examples/server/oauth-keycloak/server.php | 45 +- examples/server/oauth-microsoft/.env.dist | 3 + examples/server/oauth-microsoft/Dockerfile | 2 +- .../server/oauth-microsoft/McpElements.php | 23 +- .../MicrosoftJwtTokenValidator.php | 184 +++++++++ .../MicrosoftOidcMetadataPolicy.php | 38 ++ examples/server/oauth-microsoft/README.md | 10 +- .../server/oauth-microsoft/docker-compose.yml | 19 +- examples/server/oauth-microsoft/server.php | 59 ++- .../Unit/MicrosoftJwtTokenValidatorTest.php | 294 +++++++++++++ .../Unit/MicrosoftOidcMetadataPolicyTest.php | 64 +++ phpunit.xml.dist | 5 +- .../Middleware/AuthorizationMiddleware.php | 118 +----- .../Middleware/OAuthProxyMiddleware.php | 108 ++--- .../Middleware/OAuthRequestMetaMiddleware.php | 149 +++++++ .../ProtectedResourceMetadataMiddleware.php | 64 +++ .../OAuth}/AuthorizationResult.php | 2 +- .../AuthorizationTokenValidatorInterface.php | 9 +- .../Transport/Http/OAuth/JwksProvider.php | 132 ++++++ .../Http/OAuth/JwksProviderInterface.php | 28 ++ .../Http/OAuth/JwtTokenValidator.php | 218 ++++++++++ .../OAuth}/OidcDiscovery.php | 153 +++---- .../Http/OAuth/OidcDiscoveryInterface.php | 31 ++ .../OidcDiscoveryMetadataPolicyInterface.php | 22 + .../Http/OAuth/ProtectedResourceMetadata.php | 245 +++++++++++ .../StrictOidcDiscoveryMetadataPolicy.php | 48 +++ .../Middleware/JwtTokenValidator.php | 391 ------------------ .../Middleware/ProtectedResourceMetadata.php | 90 ---- .../AuthorizationMiddlewareTest.php | 153 ++++--- .../Middleware/OAuthProxyMiddlewareTest.php | 220 ++++++++++ .../OAuthRequestMetaMiddlewareTest.php | 179 ++++++++ ...rotectedResourceMetadataMiddlewareTest.php | 125 ++++++ .../Transport/Http/OAuth/JwksProviderTest.php | 160 +++++++ .../OAuth}/JwtTokenValidatorTest.php | 194 ++------- .../Http/OAuth/OidcDiscoveryTest.php | 268 ++++++++++++ .../OAuth/ProtectedResourceMetadataTest.php | 86 ++++ .../StrictOidcDiscoveryMetadataPolicyTest.php | 79 ++++ 44 files changed, 3095 insertions(+), 1026 deletions(-) create mode 100644 .docker/nginx.local.conf create mode 100644 .docker/php-local.Dockerfile create mode 100644 docker-compose.local.yml create mode 100644 examples/server/oauth-microsoft/.env.dist create mode 100644 examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php create mode 100644 examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php create mode 100644 examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php create mode 100644 examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php rename src/Server/Transport/{ => Http}/Middleware/AuthorizationMiddleware.php (53%) rename src/Server/Transport/{ => Http}/Middleware/OAuthProxyMiddleware.php (67%) create mode 100644 src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php create mode 100644 src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php rename src/Server/Transport/{Middleware => Http/OAuth}/AuthorizationResult.php (98%) rename src/Server/Transport/{Middleware => Http/OAuth}/AuthorizationTokenValidatorInterface.php (66%) create mode 100644 src/Server/Transport/Http/OAuth/JwksProvider.php create mode 100644 src/Server/Transport/Http/OAuth/JwksProviderInterface.php create mode 100644 src/Server/Transport/Http/OAuth/JwtTokenValidator.php rename src/Server/Transport/{Middleware => Http/OAuth}/OidcDiscovery.php (70%) create mode 100644 src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php create mode 100644 src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php create mode 100644 src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php create mode 100644 src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php delete mode 100644 src/Server/Transport/Middleware/JwtTokenValidator.php delete mode 100644 src/Server/Transport/Middleware/ProtectedResourceMetadata.php rename tests/Unit/Server/Transport/{ => Http}/Middleware/AuthorizationMiddlewareTest.php (59%) create mode 100644 tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php rename tests/Unit/Server/Transport/{Middleware => Http/OAuth}/JwtTokenValidatorTest.php (73%) create mode 100644 tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php diff --git a/.docker/nginx.local.conf b/.docker/nginx.local.conf new file mode 100644 index 00000000..51f212c7 --- /dev/null +++ b/.docker/nginx.local.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name _; + + # Route all requests to the conformance test server script. + location / { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /app/tests/Conformance/server.php; + fastcgi_param SCRIPT_NAME /tests/Conformance/server.php; + fastcgi_param QUERY_STRING $query_string; + fastcgi_pass php-fpm:9000; + } +} diff --git a/.docker/php-local.Dockerfile b/.docker/php-local.Dockerfile new file mode 100644 index 00000000..1657b3cd --- /dev/null +++ b/.docker/php-local.Dockerfile @@ -0,0 +1,11 @@ +FROM composer:2.8 + +RUN set -eux; \ + apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers; \ + pecl install xdebug; \ + docker-php-ext-enable xdebug; \ + apk del .build-deps; \ + { \ + echo 'xdebug.mode=coverage'; \ + echo 'xdebug.start_with_request=no'; \ + } > /usr/local/etc/php/conf.d/zz-xdebug.ini diff --git a/composer.json b/composer.json index 34dde03b..89ccf06c 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "require-dev": { - "firebase/php-jwt": "^6.10", + "ext-openssl": "*", + "firebase/php-jwt": "^6.10 || ^7.0", "laminas/laminas-httphandlerrunner": "^2.12", "nyholm/psr7": "^1.8", "nyholm/psr7-server": "^1.1", @@ -48,8 +49,8 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", - "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0", - "ext-openssl": "*" + "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "autoload": { "psr-4": { diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..a539379a --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,39 @@ +services: + php: + build: + context: . + dockerfile: .docker/php-local.Dockerfile + working_dir: /app + user: "${UID:-1000}:${GID:-1000}" + environment: + COMPOSER_ALLOW_SUPERUSER: "1" + COMPOSER_HOME: /tmp/composer + COMPOSER_CACHE_DIR: /tmp/composer/cache + XDEBUG_MODE: coverage + volumes: + - ./:/app + - composer-cache:/tmp/composer/cache + tty: true + stdin_open: true + command: sh -lc "while sleep 3600; do :; done" + + php-fpm: + image: php:8.4-fpm-alpine + profiles: ["http"] + working_dir: /app + volumes: + - ./:/app + + nginx: + image: nginx:1.26-alpine + profiles: ["http"] + ports: + - "8000:80" + volumes: + - ./.docker/nginx.local.conf:/etc/nginx/conf.d/default.conf:ro + - ./:/app:ro + depends_on: + - php-fpm + +volumes: + composer-cache: diff --git a/examples/server/oauth-keycloak/Dockerfile b/examples/server/oauth-keycloak/Dockerfile index 34b5d540..f877c73a 100644 --- a/examples/server/oauth-keycloak/Dockerfile +++ b/examples/server/oauth-keycloak/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.2-fpm-alpine +FROM php:8.1-fpm-alpine # Install dependencies RUN apk add --no-cache \ diff --git a/examples/server/oauth-keycloak/McpElements.php b/examples/server/oauth-keycloak/McpElements.php index e47dfe58..0a8d08e7 100644 --- a/examples/server/oauth-keycloak/McpElements.php +++ b/examples/server/oauth-keycloak/McpElements.php @@ -1,6 +1,5 @@ getRequest()->getMeta() ?? []; + $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : []; + $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : []; + $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : []; + return [ 'authenticated' => true, + 'provider' => 'Keycloak', 'message' => 'You have successfully authenticated with OAuth!', 'timestamp' => date('c'), + 'user' => [ + 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null), + 'username' => $claims['preferred_username'] ?? null, + 'name' => $claims['name'] ?? null, + 'email' => $claims['email'] ?? null, + 'issuer' => $claims['iss'] ?? null, + 'audience' => $claims['aud'] ?? null, + 'scopes' => $scopes, + 'expires_at' => isset($claims['exp']) && is_numeric($claims['exp']) + ? date('c', (int) $claims['exp']) + : null, + ], 'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.', ]; } diff --git a/examples/server/oauth-keycloak/docker-compose.yml b/examples/server/oauth-keycloak/docker-compose.yml index 2dca5b2c..6d188040 100644 --- a/examples/server/oauth-keycloak/docker-compose.yml +++ b/examples/server/oauth-keycloak/docker-compose.yml @@ -39,7 +39,14 @@ services: keycloak: condition: service_healthy command: > - sh -c "composer install --no-interaction --quiet 2>/dev/null || true && php-fpm" + sh -c "mkdir -p /app/examples/server/oauth-keycloak/sessions; + chmod -R 0777 /app/examples/server/oauth-keycloak/sessions; + touch /app/examples/server/oauth-keycloak/dev.log; + chmod 0666 /app/examples/server/oauth-keycloak/dev.log; + touch /app/examples/server/dev.log; + chmod 0666 /app/examples/server/dev.log; + composer install --no-interaction --quiet 2>/dev/null || true; + php-fpm" networks: - mcp-network diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php index 42e4bb4b..9677c821 100644 --- a/examples/server/oauth-keycloak/server.php +++ b/examples/server/oauth-keycloak/server.php @@ -1,6 +1,5 @@ createServerRequestFromGlobals(); // Create JWT validator -// - issuer: matches what's in the token (external URL) +// - issuer: accepts both external and internal issuer forms // - jwksUri: where to fetch keys (internal URL) $validator = new JwtTokenValidator( - issuer: $issuer, + issuer: [$externalIssuer, $internalIssuer], audience: $mcpAudience, + jwksProvider: new JwksProvider(), jwksUri: $jwksUri, ); -// Create Protected Resource Metadata (RFC 9728) -// Authorization server URL should be the external URL for clients -// scopesSupported must match what Keycloak's mcp-client allows -$metadata = new ProtectedResourceMetadata( - authorizationServers: [$issuer], +// Create a shared Protected Resource Metadata object (RFC 9728). +// It is used both for the metadata endpoint and for WWW-Authenticate hints. +$protectedResourceMetadata = new ProtectedResourceMetadata( + authorizationServers: [$externalIssuer], scopesSupported: ['openid'], resource: 'http://localhost:8000/mcp', + resourceName: 'OAuth Keycloak Example MCP Server', +); + +// Create middleware serving Protected Resource Metadata (RFC 9728). +$metadataMiddleware = new ProtectedResourceMetadataMiddleware( + metadata: $protectedResourceMetadata, ); -// Create authorization middleware +// Create authorization middleware. $authMiddleware = new AuthorizationMiddleware( - metadata: $metadata, validator: $validator, - metadataPaths: ['/.well-known/oauth-protected-resource'], + resourceMetadata: $protectedResourceMetadata, ); +$oauthRequestMetaMiddleware = new OAuthRequestMetaMiddleware(); // Build MCP server $server = Server::builder() @@ -77,7 +88,7 @@ $transport = new StreamableHttpTransport( $request, logger: logger(), - middlewares: [$authMiddleware], + middleware: [$metadataMiddleware, $authMiddleware, $oauthRequestMetaMiddleware], ); // Run server diff --git a/examples/server/oauth-microsoft/.env.dist b/examples/server/oauth-microsoft/.env.dist new file mode 100644 index 00000000..de4376e9 --- /dev/null +++ b/examples/server/oauth-microsoft/.env.dist @@ -0,0 +1,3 @@ +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= \ No newline at end of file diff --git a/examples/server/oauth-microsoft/Dockerfile b/examples/server/oauth-microsoft/Dockerfile index 34b5d540..f877c73a 100644 --- a/examples/server/oauth-microsoft/Dockerfile +++ b/examples/server/oauth-microsoft/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.2-fpm-alpine +FROM php:8.1-fpm-alpine # Install dependencies RUN apk add --no-cache \ diff --git a/examples/server/oauth-microsoft/McpElements.php b/examples/server/oauth-microsoft/McpElements.php index 6d617d11..a066a726 100644 --- a/examples/server/oauth-microsoft/McpElements.php +++ b/examples/server/oauth-microsoft/McpElements.php @@ -1,6 +1,5 @@ getRequest()->getMeta() ?? []; + $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : []; + $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : []; + $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : []; + return [ 'authenticated' => true, 'provider' => 'Microsoft Entra ID', 'message' => 'You have successfully authenticated with Microsoft!', 'timestamp' => date('c'), + 'user' => [ + 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null), + 'object_id' => $oauth['oauth.object_id'] ?? ($claims['oid'] ?? null), + 'username' => $claims['preferred_username'] ?? ($claims['upn'] ?? null), + 'name' => $oauth['oauth.name'] ?? ($claims['name'] ?? null), + 'email' => $claims['email'] ?? null, + 'issuer' => $claims['iss'] ?? null, + 'audience' => $claims['aud'] ?? null, + 'tenant_id' => $claims['tid'] ?? null, + 'scopes' => $scopes, + 'expires_at' => isset($claims['exp']) && is_numeric($claims['exp']) + ? date('c', (int) $claims['exp']) + : null, + ], ]; } diff --git a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php new file mode 100644 index 00000000..df684458 --- /dev/null +++ b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php @@ -0,0 +1,184 @@ + + */ +class MicrosoftJwtTokenValidator implements AuthorizationTokenValidatorInterface +{ + /** + * @param JwtTokenValidator $jwtTokenValidator Base JWT validator used for non-Graph tokens + * @param string $scopeClaim Claim name for scopes in Graph tokens + * @param list $trustedGraphIssuers Allowed Graph issuer host markers + * @param int $notBeforeLeewaySeconds Allowed clock skew for "nbf" claim + */ + public function __construct( + private readonly JwtTokenValidator $jwtTokenValidator, + private readonly string $scopeClaim = 'scp', + private readonly array $trustedGraphIssuers = ['sts.windows.net', 'login.microsoftonline.com'], + private readonly int $notBeforeLeewaySeconds = 60, + ) { + } + + public function validate(string $accessToken): AuthorizationResult + { + $parts = explode('.', $accessToken); + if (!$this->isGraphToken($parts)) { + return $this->jwtTokenValidator->validate($accessToken); + } + + return $this->validateGraphToken($parts); + } + + /** + * Validates a token has the required scopes. + * + * Use this after validation to check specific scope requirements. + * + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation + * + * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise + */ + public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult + { + return $this->jwtTokenValidator->requireScopes($result, $requiredScopes); + } + + /** + * @param array $parts + */ + private function isGraphToken(array $parts): bool + { + if ([] === $parts) { + return false; + } + + $header = $this->decodePartToArray($parts[0]); + if (null === $header) { + return false; + } + + return isset($header['nonce']); + } + + /** + * @param array $parts + */ + private function validateGraphToken(array $parts): AuthorizationResult + { + if (\count($parts) < 2) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); + } + + $payload = $this->decodePartToArray($parts[1]); + if (null === $payload) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.'); + } + + if (isset($payload['exp']) && is_numeric($payload['exp']) && (int) $payload['exp'] < time()) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } + + if (isset($payload['nbf']) && is_numeric($payload['nbf']) && (int) $payload['nbf'] > time() + $this->notBeforeLeewaySeconds) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); + } + + $issuer = $payload['iss'] ?? ''; + if (!\is_string($issuer) || !$this->isTrustedGraphIssuer($issuer)) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.'); + } + + $scopes = $this->extractScopes($payload); + + $attributes = [ + 'oauth.claims' => $payload, + 'oauth.scopes' => $scopes, + 'oauth.graph_token' => true, + ]; + + if (isset($payload['sub'])) { + $attributes['oauth.subject'] = $payload['sub']; + } + + if (isset($payload['oid'])) { + $attributes['oauth.object_id'] = $payload['oid']; + } + + if (isset($payload['name'])) { + $attributes['oauth.name'] = $payload['name']; + } + + return AuthorizationResult::allow($attributes); + } + + private function isTrustedGraphIssuer(string $issuer): bool + { + foreach ($this->trustedGraphIssuers as $marker) { + if (str_contains($issuer, $marker)) { + return true; + } + } + + return false; + } + + /** + * @param array $claims + * + * @return list + */ + private function extractScopes(array $claims): array + { + if (!isset($claims[$this->scopeClaim])) { + return []; + } + + $scopeValue = $claims[$this->scopeClaim]; + + if (\is_array($scopeValue)) { + return array_values(array_filter($scopeValue, 'is_string')); + } + + if (\is_string($scopeValue)) { + return array_values(array_filter(explode(' ', $scopeValue))); + } + + return []; + } + + /** + * @return array|null + */ + private function decodePartToArray(string $part): ?array + { + $decoded = base64_decode(strtr($part, '-_', '+/')); + if (false === $decoded) { + return null; + } + + $data = json_decode($decoded, true); + + return \is_array($data) ? $data : null; + } +} diff --git a/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php b/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php new file mode 100644 index 00000000..fb16cda8 --- /dev/null +++ b/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php @@ -0,0 +1,38 @@ + + */ +final class MicrosoftOidcMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool + { + return \is_array($metadata) + && isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) + && \is_string($metadata['authorization_endpoint']) + && '' !== trim($metadata['authorization_endpoint']) + && \is_string($metadata['token_endpoint']) + && '' !== trim($metadata['token_endpoint']) + && \is_string($metadata['jwks_uri']) + && '' !== trim($metadata['jwks_uri']); + } +} diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md index 0a98121f..1d7a6540 100644 --- a/examples/server/oauth-microsoft/README.md +++ b/examples/server/oauth-microsoft/README.md @@ -5,6 +5,7 @@ This example demonstrates MCP server authorization using Microsoft Entra ID (for ## Features - JWT token validation with Microsoft Entra ID +- Microsoft-specific validator/discovery overrides for Entra quirks - Protected Resource Metadata (RFC 9728) - MCP tools that access Microsoft claims - Optional Microsoft Graph API integration @@ -148,6 +149,8 @@ curl -X POST http://localhost:8000/mcp \ - `nginx/default.conf` - Nginx configuration - `env.example` - Environment variables template - `server.php` - MCP server with OAuth middleware +- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens +- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy - `McpElements.php` - MCP tools including Graph API integration ## Environment Variables @@ -180,7 +183,7 @@ Microsoft uses different issuer URLs depending on the token flow: - v2.0 endpoint (user/delegated flows): `https://login.microsoftonline.com/{tenant}/v2.0` - v1.0 endpoint (client credentials flow): `https://sts.windows.net/{tenant}/` -This example **automatically accepts both formats** by configuring multiple issuers in the `JwtTokenValidator`. +This example **automatically accepts both formats** by configuring multiple issuers in the `MicrosoftJwtTokenValidator`. Check your token's `iss` claim to verify which format is being used. ### "Invalid audience" error @@ -193,6 +196,11 @@ the audience might be `api://your-client-id`. Microsoft's JWKS endpoint is public. Ensure your container can reach: `https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys` +### `code_challenge_methods_supported` missing in discovery metadata + +This example configures `OidcDiscovery` with `MicrosoftOidcMetadataPolicy`, so this +field can be missing or malformed and will not fail discovery. + ### Graph API errors 1. Ensure `AZURE_CLIENT_SECRET` is set diff --git a/examples/server/oauth-microsoft/docker-compose.yml b/examples/server/oauth-microsoft/docker-compose.yml index c4312d71..4b02d65b 100644 --- a/examples/server/oauth-microsoft/docker-compose.yml +++ b/examples/server/oauth-microsoft/docker-compose.yml @@ -5,13 +5,23 @@ services: dockerfile: Dockerfile container_name: mcp-php-microsoft volumes: - - ../../../:/app:ro - - ./server.php:/app/examples/server/oauth-microsoft/server.php:ro - - ./McpElements.php:/app/examples/server/oauth-microsoft/McpElements.php:ro + - ../../../:/app + working_dir: /app + env_file: + - .env environment: AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + command: > + sh -c "mkdir -p /app/examples/server/oauth-microsoft/sessions; + chmod -R 0777 /app/examples/server/oauth-microsoft/sessions; + touch /app/examples/server/oauth-microsoft/dev.log; + chmod 0666 /app/examples/server/oauth-microsoft/dev.log; + touch /app/examples/server/dev.log; + chmod 0666 /app/examples/server/dev.log; + composer install --no-interaction --quiet 2>/dev/null || true; + php-fpm" networks: - mcp-network @@ -19,9 +29,10 @@ services: image: nginx:alpine container_name: mcp-nginx-microsoft ports: - - "8000:80" + - "${MCP_HTTP_PORT:-8000}:80" volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../../../:/app:ro depends_on: - php networks: diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index 8e03379b..20a17cbb 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -1,6 +1,5 @@ createServerRequestFromGlobals(); -// Create JWT validator for Microsoft Entra ID +// Create base JWT validator for Microsoft Entra ID // Microsoft uses the client ID as the audience for access tokens // Accept both v1.0 and v2.0 issuers to support various token flows -$validator = new JwtTokenValidator( +$jwtTokenValidator = new JwtTokenValidator( issuer: $issuers, audience: $clientId, + jwksProvider: new JwksProvider(), // Microsoft's JWKS endpoint - use common endpoint for all Microsoft signing keys jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys', + scopeClaim: 'scp', +); + +// Decorate base validator with Graph-token handling. +$validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, ); -// Create Protected Resource Metadata (RFC 9728) -// Point to local authorization server (which proxies to Microsoft) -// This allows mcp-remote to use our /authorize and /token endpoints -$metadata = new ProtectedResourceMetadata( - authorizationServers: ['http://localhost:8000'], +// Create a shared Protected Resource Metadata object (RFC 9728). +// It is used both for the metadata endpoint and for WWW-Authenticate hints. +$protectedResourceMetadata = new ProtectedResourceMetadata( + authorizationServers: [$localBaseUrl], scopesSupported: ['openid', 'profile', 'email'], - resource: null, + resourceName: 'OAuth Microsoft Example MCP Server', + resourceDocumentation: $localBaseUrl, +); + +// Create middleware serving Protected Resource Metadata (RFC 9728). +$metadataMiddleware = new ProtectedResourceMetadataMiddleware( + metadata: $protectedResourceMetadata, ); // Get client secret for confidential client flow @@ -66,16 +86,19 @@ // The clientSecret is injected server-side since mcp-remote doesn't have access to it $oauthProxyMiddleware = new OAuthProxyMiddleware( upstreamIssuer: $issuerV2, - localBaseUrl: 'http://localhost:8000', + localBaseUrl: $localBaseUrl, + discovery: new OidcDiscovery( + metadataPolicy: new MicrosoftOidcMetadataPolicy(), + ), clientSecret: $clientSecret, ); // Create authorization middleware $authMiddleware = new AuthorizationMiddleware( - metadata: $metadata, validator: $validator, - metadataPaths: ['/.well-known/oauth-protected-resource'], + resourceMetadata: $protectedResourceMetadata, ); +$oauthRequestMetaMiddleware = new OAuthRequestMetaMiddleware(); // Build MCP server $server = Server::builder() @@ -86,11 +109,11 @@ ->build(); // Create transport with OAuth proxy and authorization middlewares -// Middlewares are reversed internally, so put OAuth proxy FIRST to execute FIRST +// Order matters: first matching middleware handles the request. $transport = new StreamableHttpTransport( $request, logger: logger(), - middlewares: [$oauthProxyMiddleware, $authMiddleware], + middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, $oauthRequestMetaMiddleware], ); // Run server diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php new file mode 100644 index 00000000..3e8d4a3a --- /dev/null +++ b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php @@ -0,0 +1,294 @@ + + */ +class MicrosoftJwtTokenValidatorTest extends TestCase +{ + #[TestDox('non-Graph Microsoft token is validated via JWKS')] + public function testNonGraphTokenUsesStandardJwtValidation(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://login.microsoftonline.com/common/discovery/v2.0/keys'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: 'https://login.microsoftonline.com/tenant-id/v2.0', + audience: 'mcp-api', + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksUri: $jwksUri, + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator(jwtTokenValidator: $jwtTokenValidator); + + $token = JWT::encode( + [ + 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scp' => 'files.read files.write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']); + $this->assertSame('user-123', $result->getAttributes()['oauth.subject']); + $this->assertArrayNotHasKey('oauth.graph_token', $result->getAttributes()); + } + + #[TestDox('Graph token with nonce header is validated by claims only')] + public function testGraphTokenWithNonceHeaderIsAllowed(): void + { + $factory = new Psr17Factory(); + $token = $this->buildGraphToken([ + 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read files.write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider( + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $this->assertTrue($result->getAttributes()['oauth.graph_token']); + $this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']); + $this->assertSame('user-graph', $result->getAttributes()['oauth.subject']); + } + + #[TestDox('Graph token with invalid payload is unauthorized')] + public function testGraphTokenInvalidPayloadIsUnauthorized(): void + { + $factory = new Psr17Factory(); + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + $token = $header.'..'; + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider( + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token payload.', $result->getErrorDescription()); + } + + #[TestDox('Graph token with invalid issuer is unauthorized')] + public function testGraphTokenInvalidIssuerIsUnauthorized(): void + { + $factory = new Psr17Factory(); + $token = $this->buildGraphToken([ + 'iss' => 'https://evil.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider( + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription()); + } + + #[TestDox('scope checks are delegated to base JwtTokenValidator')] + public function testRequireScopesDelegatesToJwtTokenValidator(): void + { + $factory = new Psr17Factory(); + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider( + httpClient: $this->createHttpClientMock([], 0), + requestFactory: $factory, + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = AuthorizationResult::allow([ + 'oauth.scopes' => ['files.read'], + ]); + $scoped = $validator->requireScopes($result, ['files.read', 'files.write']); + + $this->assertFalse($scoped->isAllowed()); + $this->assertSame(403, $scoped->getStatusCode()); + $this->assertSame('insufficient_scope', $scoped->getError()); + } + + /** + * @param array $claims + */ + private function buildGraphToken(array $claims): string + { + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); + + return $header.'.'.$payload.'.'; + } + + /** + * @return array{0: string, 1: array} + */ + private function generateRsaKeypairAsJwk(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + + if (false === $key) { + $this->fail('Failed to generate RSA keypair via OpenSSL.'); + } + + $privateKeyPem = ''; + if (!openssl_pkey_export($key, $privateKeyPem)) { + $this->fail('Failed to export RSA private key.'); + } + + $details = openssl_pkey_get_details($key); + if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { + $this->fail('Failed to read RSA key details.'); + } + + $n = $this->b64urlEncode($details['rsa']['n']); + $e = $this->b64urlEncode($details['rsa']['e']); + + $publicJwk = [ + 'kty' => 'RSA', + 'kid' => $kid, + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $n, + 'e' => $e, + ]; + + return [$privateKeyPem, $publicJwk]; + } + + private function b64urlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * @param list $responses + */ + private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface + { + $expectedCalls ??= \count($responses); + + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->exactly($expectedCalls)) + ->method('sendRequest') + ->with($this->isInstanceOf(RequestInterface::class)) + ->willReturnCallback(static function () use (&$responses): ResponseInterface { + if ([] === $responses) { + throw new \RuntimeException('No more mocked responses available.'); + } + + return array_shift($responses); + }); + + return $client; + } +} diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php new file mode 100644 index 00000000..218e0fbd --- /dev/null +++ b/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php @@ -0,0 +1,64 @@ + + */ +class MicrosoftOidcMetadataPolicyTest extends TestCase +{ + #[TestDox('metadata without code challenge methods is accepted')] + public function testMissingCodeChallengeMethodsIsAccepted(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('malformed code challenge methods are ignored for validity')] + public function testMalformedCodeChallengeMethodsSupportedIsAccepted(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => 'S256', + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('required endpoint fields still enforce validity')] + public function testIsValidRequiresCoreEndpoints(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + // token_endpoint missing + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertFalse($policy->isValid($metadata)); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d5adf9e1..50c13a22 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ tests/Unit + + examples/server/oauth-microsoft/tests + tests/Inspector diff --git a/src/Server/Transport/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php similarity index 53% rename from src/Server/Transport/Middleware/AuthorizationMiddleware.php rename to src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php index 7926ed3c..623b7648 100644 --- a/src/Server/Transport/Middleware/AuthorizationMiddleware.php +++ b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php @@ -9,21 +9,23 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\Transport\Middleware; +namespace Mcp\Server\Transport\Http\Middleware; use Http\Discovery\Psr17FactoryDiscovery; +use Mcp\Exception\RuntimeException; +use Mcp\Server\Transport\Http\OAuth\AuthorizationResult; +use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface; +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; /** - * Enforces MCP HTTP authorization requirements and serves protected resource metadata. + * Enforces MCP HTTP authorization requirements. * * This middleware: - * - Serves Protected Resource Metadata (RFC 9728) at configured well-known paths * - Validates Bearer tokens via the configured validator * - Returns 401 with WWW-Authenticate header on missing/invalid tokens * - Returns 403 on insufficient scope @@ -35,45 +37,22 @@ final class AuthorizationMiddleware implements MiddlewareInterface { private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; - - /** @var list */ - private array $metadataPaths; - - /** @var callable(ServerRequestInterface): list|null */ - private $scopeProvider; /** - * @param ProtectedResourceMetadata $metadata The protected resource metadata to serve - * @param AuthorizationTokenValidatorInterface $validator Token validator implementation - * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) - * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) - * @param list $metadataPaths Paths where metadata should be served (e.g., ["/.well-known/oauth-protected-resource"]) - * @param string|null $resourceMetadataUrl Explicit URL for the resource_metadata in WWW-Authenticate - * @param callable(ServerRequestInterface): list|null $scopeProvider Optional callback to determine required scopes per request + * @param AuthorizationTokenValidatorInterface $validator Token validator implementation + * @param ProtectedResourceMetadata $resourceMetadata Protected resource metadata object used for challenge hints + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) */ public function __construct( - private ProtectedResourceMetadata $metadata, private AuthorizationTokenValidatorInterface $validator, + private ProtectedResourceMetadata $resourceMetadata, ?ResponseFactoryInterface $responseFactory = null, - ?StreamFactoryInterface $streamFactory = null, - array $metadataPaths = [], - private ?string $resourceMetadataUrl = null, - ?callable $scopeProvider = null, ) { $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - - $this->metadataPaths = $this->normalizePaths($metadataPaths); - $this->scopeProvider = $scopeProvider; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - if ($this->isMetadataRequest($request)) { - return $this->createMetadataResponse(); - } - $authorization = $request->getHeaderLine('Authorization'); if ('' === $authorization) { return $this->buildErrorResponse($request, AuthorizationResult::unauthorized()); @@ -87,7 +66,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface ); } - $result = $this->validator->validate($request, $accessToken); + $result = $this->validator->validate($accessToken); if (!$result->isAllowed()) { return $this->buildErrorResponse($request, $result); } @@ -95,23 +74,6 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $handler->handle($this->applyAttributes($request, $result->getAttributes())); } - private function createMetadataResponse(): ResponseInterface - { - return $this->responseFactory - ->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($this->metadata->toJson())); - } - - private function isMetadataRequest(ServerRequestInterface $request): bool - { - if ([] === $this->metadataPaths || 'GET' !== $request->getMethod()) { - return false; - } - - return \in_array($request->getUri()->getPath(), $this->metadataPaths, true); - } - private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface { $response = $this->responseFactory->createResponse($result->getStatusCode()); @@ -128,12 +90,9 @@ private function buildAuthenticateHeader(ServerRequestInterface $request, Author { $parts = []; - $resourceMetadataUrl = $this->resolveResourceMetadataUrl($request); - if (null !== $resourceMetadataUrl) { - $parts[] = 'resource_metadata="'.$this->escapeHeaderValue($resourceMetadataUrl).'"'; - } + $parts[] = 'resource_metadata="'.$this->escapeHeaderValue($this->resolveResourceMetadataUrl($request)).'"'; - $scopes = $this->resolveScopes($request, $result); + $scopes = $this->resolveScopes($result); if (null !== $scopes) { $parts[] = 'scope="'.$this->escapeHeaderValue(implode(' ', $scopes)).'"'; } @@ -156,22 +115,14 @@ private function buildAuthenticateHeader(ServerRequestInterface $request, Author /** * @return list|null */ - private function resolveScopes(ServerRequestInterface $request, AuthorizationResult $result): ?array + private function resolveScopes(AuthorizationResult $result): ?array { $scopes = $this->normalizeScopes($result->getScopes()); if (null !== $scopes) { return $scopes; } - if (null !== $this->scopeProvider) { - $provided = ($this->scopeProvider)($request); - $scopes = $this->normalizeScopes($provided); - if (null !== $scopes) { - return $scopes; - } - } - - return $this->normalizeScopes($this->metadata->getScopesSupported()); + return $this->normalizeScopes($this->resourceMetadata->getScopesSupported()); } /** @@ -192,25 +143,19 @@ private function normalizeScopes(?array $scopes): ?array return [] === $normalized ? null : $normalized; } - private function resolveResourceMetadataUrl(ServerRequestInterface $request): ?string + private function resolveResourceMetadataUrl(ServerRequestInterface $request): string { - if (null !== $this->resourceMetadataUrl) { - return $this->resourceMetadataUrl; - } - - if ([] === $this->metadataPaths) { - return null; - } + $metadataPath = $this->resourceMetadata->getPrimaryMetadataPath(); $uri = $request->getUri(); $scheme = $uri->getScheme(); $authority = $uri->getAuthority(); if ('' === $scheme || '' === $authority) { - return null; + throw new RuntimeException('Cannot resolve resource metadata URL: request URI must have scheme and authority'); } - return $scheme.'://'.$authority.$this->metadataPaths[0]; + return $scheme.'://'.$authority.$metadataPath; } /** @@ -225,32 +170,9 @@ private function applyAttributes(ServerRequestInterface $request, array $attribu return $request; } - /** - * @param list $paths - * - * @return list - */ - private function normalizePaths(array $paths): array - { - $normalized = []; - - foreach ($paths as $path) { - $path = trim($path); - if ('' === $path) { - continue; - } - if ('/' !== $path[0]) { - $path = '/'.$path; - } - $normalized[] = $path; - } - - return array_values(array_unique($normalized)); - } - private function parseBearerToken(string $authorization): ?string { - if (!preg_match('/^Bearer\\s+(.+)$/i', $authorization, $matches)) { + if (!preg_match('/^Bearer\\s+(.+)$/', $authorization, $matches)) { return null; } diff --git a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php similarity index 67% rename from src/Server/Transport/Middleware/OAuthProxyMiddleware.php rename to src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php index 1489c073..ba498c2e 100644 --- a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php @@ -9,10 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\Transport\Middleware; +namespace Mcp\Server\Transport\Http\Middleware; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; +use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ResponseFactoryInterface; @@ -37,29 +38,29 @@ */ final class OAuthProxyMiddleware implements MiddlewareInterface { - private ClientInterface $httpClient; - private RequestFactoryInterface $requestFactory; + private ?ClientInterface $httpClient; + private ?RequestFactoryInterface $requestFactory; private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; - private ?array $upstreamMetadata = null; - /** - * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider - * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) - * @param string|null $clientSecret Optional client secret for confidential clients + * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider + * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) + * @param string|null $clientSecret Optional client secret for confidential clients + * @param OidcDiscoveryInterface $discovery OIDC discovery provider for upstream metadata */ public function __construct( private readonly string $upstreamIssuer, private readonly string $localBaseUrl, + private readonly OidcDiscoveryInterface $discovery, private readonly ?string $clientSecret = null, ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ) { - $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); - $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->httpClient = $httpClient; + $this->requestFactory = $requestFactory; $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } @@ -68,41 +69,35 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface { $path = $request->getUri()->getPath(); - // Serve local authorization server metadata if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) { return $this->createAuthServerMetadataResponse(); } - // Handle authorization endpoint - redirect to upstream if ('GET' === $request->getMethod() && '/authorize' === $path) { return $this->handleAuthorize($request); } - // Handle token endpoint - proxy to upstream if ('POST' === $request->getMethod() && '/token' === $path) { return $this->handleToken($request); } - // Pass through to next handler return $handler->handle($request); } private function handleAuthorize(ServerRequestInterface $request): ResponseInterface { - $upstreamMetadata = $this->getUpstreamMetadata(); - $authorizationEndpoint = $upstreamMetadata['authorization_endpoint'] ?? null; - - if (null === $authorizationEndpoint) { + try { + $authorizationEndpoint = $this->discovery->getAuthorizationEndpoint($this->upstreamIssuer); + } catch (\Throwable) { return $this->createErrorResponse(500, 'Upstream authorization endpoint not found'); } - // Get the raw query string to preserve exact encoding (important for PKCE) $rawQueryString = $request->getUri()->getQuery(); + $upstreamUrl = $authorizationEndpoint; + if ('' !== $rawQueryString) { + $upstreamUrl .= '?'.$rawQueryString; + } - // Build upstream URL preserving exact query string - $upstreamUrl = $authorizationEndpoint.'?'.$rawQueryString; - - // Redirect to upstream authorization server return $this->responseFactory ->createResponse(302) ->withHeader('Location', $upstreamUrl) @@ -111,41 +106,34 @@ private function handleAuthorize(ServerRequestInterface $request): ResponseInter private function handleToken(ServerRequestInterface $request): ResponseInterface { - $upstreamMetadata = $this->getUpstreamMetadata(); - $tokenEndpoint = $upstreamMetadata['token_endpoint'] ?? null; - - if (null === $tokenEndpoint) { + try { + $tokenEndpoint = $this->discovery->getTokenEndpoint($this->upstreamIssuer); + } catch (\Throwable) { return $this->createErrorResponse(500, 'Upstream token endpoint not found'); } - // Get the request body and parse it $body = $request->getBody()->__toString(); parse_str($body, $params); - // Inject client_secret if configured and not already present if (null !== $this->clientSecret && !isset($params['client_secret'])) { $params['client_secret'] = $this->clientSecret; } - // Rebuild body with potentially added client_secret $body = http_build_query($params); - // Create upstream request - $upstreamRequest = $this->requestFactory + $upstreamRequest = $this->getRequestFactory() ->createRequest('POST', $tokenEndpoint) ->withHeader('Content-Type', 'application/x-www-form-urlencoded') ->withBody($this->streamFactory->createStream($body)); - // Forward any Authorization header (for client credentials) if ($request->hasHeader('Authorization')) { $upstreamRequest = $upstreamRequest->withHeader('Authorization', $request->getHeaderLine('Authorization')); } try { - $upstreamResponse = $this->httpClient->sendRequest($upstreamRequest); + $upstreamResponse = $this->getHttpClient()->sendRequest($upstreamRequest); $responseBody = $upstreamResponse->getBody()->__toString(); - // Return upstream response as-is return $this->responseFactory ->createResponse($upstreamResponse->getStatusCode()) ->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type')) @@ -158,9 +146,12 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface private function createAuthServerMetadataResponse(): ResponseInterface { - $upstreamMetadata = $this->getUpstreamMetadata(); + try { + $upstreamMetadata = $this->discovery->discover($this->upstreamIssuer); + } catch (\Throwable) { + return $this->createErrorResponse(500, 'Failed to discover upstream server metadata'); + } - // Create local metadata that points to our proxy endpoints $localMetadata = [ 'issuer' => $this->upstreamIssuer, 'authorization_endpoint' => rtrim($this->localBaseUrl, '/').'/authorize', @@ -170,7 +161,6 @@ private function createAuthServerMetadataResponse(): ResponseInterface 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], ]; - // Copy additional useful fields from upstream $copyFields = [ 'scopes_supported', 'token_endpoint_auth_methods_supported', @@ -190,38 +180,6 @@ private function createAuthServerMetadataResponse(): ResponseInterface ->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_UNESCAPED_SLASHES))); } - private function getUpstreamMetadata(): array - { - if (null !== $this->upstreamMetadata) { - return $this->upstreamMetadata; - } - - // Try OpenID Connect discovery first - $discoveryUrls = [ - rtrim($this->upstreamIssuer, '/').'/.well-known/openid-configuration', - rtrim($this->upstreamIssuer, '/').'/.well-known/oauth-authorization-server', - ]; - - foreach ($discoveryUrls as $url) { - try { - $request = $this->requestFactory->createRequest('GET', $url); - $response = $this->httpClient->sendRequest($request); - - if (200 === $response->getStatusCode()) { - $this->upstreamMetadata = json_decode($response->getBody()->__toString(), true) ?? []; - - return $this->upstreamMetadata; - } - } catch (\Throwable) { - // Try next URL - } - } - - $this->upstreamMetadata = []; - - return $this->upstreamMetadata; - } - private function createErrorResponse(int $status, string $message): ResponseInterface { $body = json_encode(['error' => 'server_error', 'error_description' => $message]); @@ -231,4 +189,14 @@ private function createErrorResponse(int $status, string $message): ResponseInte ->withHeader('Content-Type', 'application/json') ->withBody($this->streamFactory->createStream($body)); } + + private function getHttpClient(): ClientInterface + { + return $this->httpClient ??= Psr18ClientDiscovery::find(); + } + + private function getRequestFactory(): RequestFactoryInterface + { + return $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); + } } diff --git a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php new file mode 100644 index 00000000..9b940dab --- /dev/null +++ b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php @@ -0,0 +1,149 @@ + + */ +final class OAuthRequestMetaMiddleware implements MiddlewareInterface +{ + public function __construct( + private ?StreamFactoryInterface $streamFactory = null, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ('POST' !== $request->getMethod()) { + return $handler->handle($request); + } + + $oauthMeta = $this->extractOAuthAttributes($request); + if ([] === $oauthMeta) { + return $handler->handle($request); + } + + $body = (string) $request->getBody(); + if ('' === trim($body)) { + return $handler->handle($request); + } + + try { + $payload = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $handler->handle($request); + } + + $updatedPayload = $this->injectOauthMeta($payload, $oauthMeta); + if (null === $updatedPayload) { + return $handler->handle($request); + } + + try { + $updatedBody = json_encode($updatedPayload, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); + } catch (\JsonException) { + return $handler->handle($request); + } + + $request = $request->withBody($this->getStreamFactory()->createStream($updatedBody)); + + return $handler->handle($request); + } + + /** + * @return array + */ + private function extractOAuthAttributes(ServerRequestInterface $request): array + { + $result = []; + foreach ($request->getAttributes() as $key => $value) { + if (\is_string($key) && str_starts_with($key, 'oauth.')) { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * @param array $oauthMeta + */ + private function injectOauthMeta(mixed $payload, array $oauthMeta): mixed + { + if (!\is_array($payload)) { + return null; + } + + if (array_is_list($payload)) { + $updated = []; + foreach ($payload as $entry) { + if (!\is_array($entry)) { + $updated[] = $entry; + continue; + } + + $updated[] = $this->injectIntoMessage($entry, $oauthMeta); + } + + return $updated; + } + + return $this->injectIntoMessage($payload, $oauthMeta); + } + + /** + * @param array $message + * @param array $oauthMeta + * + * @return array + */ + private function injectIntoMessage(array $message, array $oauthMeta): array + { + $params = $message['params'] ?? []; + if (!\is_array($params)) { + return $message; + } + + $meta = $params['_meta'] ?? []; + if (!\is_array($meta)) { + $meta = []; + } + + $existingOAuth = $meta['oauth'] ?? []; + if (!\is_array($existingOAuth)) { + $existingOAuth = []; + } + + $meta['oauth'] = array_merge($existingOAuth, $oauthMeta); + $params['_meta'] = $meta; + $message['params'] = $params; + + return $message; + } + + private function getStreamFactory(): StreamFactoryInterface + { + return $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); + } +} diff --git a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php new file mode 100644 index 00000000..a3fc3f93 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php @@ -0,0 +1,64 @@ + + */ +final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + public function __construct( + private readonly ProtectedResourceMetadata $metadata, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (!$this->isMetadataRequest($request)) { + return $handler->handle($request); + } + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR))); + } + + private function isMetadataRequest(ServerRequestInterface $request): bool + { + if ('GET' !== $request->getMethod()) { + return false; + } + + return \in_array($request->getUri()->getPath(), $this->metadata->getMetadataPaths(), true); + } +} diff --git a/src/Server/Transport/Middleware/AuthorizationResult.php b/src/Server/Transport/Http/OAuth/AuthorizationResult.php similarity index 98% rename from src/Server/Transport/Middleware/AuthorizationResult.php rename to src/Server/Transport/Http/OAuth/AuthorizationResult.php index c1600bc4..4db13f4c 100644 --- a/src/Server/Transport/Middleware/AuthorizationResult.php +++ b/src/Server/Transport/Http/OAuth/AuthorizationResult.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\Transport\Middleware; +namespace Mcp\Server\Transport\Http\OAuth; /** * Describes the outcome of an authorization decision. diff --git a/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php b/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php similarity index 66% rename from src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php rename to src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php index 70a2043c..78b849eb 100644 --- a/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php +++ b/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php @@ -9,9 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\Transport\Middleware; - -use Psr\Http\Message\ServerRequestInterface; +namespace Mcp\Server\Transport\Http\OAuth; /** * Validates bearer tokens for HTTP transports. @@ -26,10 +24,9 @@ interface AuthorizationTokenValidatorInterface /** * Validates an access token extracted from the Authorization header. * - * @param ServerRequestInterface $request The incoming HTTP request - * @param string $accessToken The bearer token (without "Bearer " prefix) + * @param string $accessToken The bearer token (without "Bearer " prefix) * * @return AuthorizationResult The result of the validation */ - public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult; + public function validate(string $accessToken): AuthorizationResult; } diff --git a/src/Server/Transport/Http/OAuth/JwksProvider.php b/src/Server/Transport/Http/OAuth/JwksProvider.php new file mode 100644 index 00000000..ba113968 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwksProvider.php @@ -0,0 +1,132 @@ + + */ +class JwksProvider implements JwksProviderInterface +{ + private const CACHE_KEY_PREFIX = 'mcp_jwks_'; + + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private ?OidcDiscoveryInterface $discovery; + + public function __construct( + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?OidcDiscoveryInterface $discovery = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->discovery = $discovery; + } + + /** + * @return array + */ + public function getJwks(string $issuer, ?string $jwksUri = null): array + { + $jwksUri ??= $this->resolveJwksUri($issuer); + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if ($this->isJwksValid($cached)) { + /* @var array $cached */ + return $cached; + } + } + + $jwks = $this->fetchJwks($jwksUri); + + if (!$this->isJwksValid($jwks)) { + throw new RuntimeException(\sprintf('JWKS response from %s has invalid format: expected non-empty "keys" array.', $jwksUri)); + } + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $jwks, $this->cacheTtl); + } + + return $jwks; + } + + private function resolveJwksUri(string $issuer): string + { + if (null === $this->discovery) { + $this->discovery = new OidcDiscovery( + $this->httpClient, + $this->requestFactory, + $this->cache, + $this->cacheTtl, + ); + } + + return $this->discovery->getJwksUri($issuer); + } + + /** + * @return array + */ + private function fetchJwks(string $jwksUri): array + { + $request = $this->requestFactory->createRequest('GET', $jwksUri) + ->withHeader('Accept', 'application/json'); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: %s', $jwksUri, $e->getMessage()), 0, $e); + } + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode())); + } + + $body = $response->getBody()->__toString(); + + try { + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + } + + if (!\is_array($data)) { + throw new RuntimeException('Invalid JWKS format: expected JSON object.'); + } + + return $data; + } + + private function isJwksValid(mixed $jwks): bool + { + if (!\is_array($jwks) || !isset($jwks['keys']) || !\is_array($jwks['keys'])) { + return false; + } + + $nonEmptyKeys = array_filter($jwks['keys'], static fn (mixed $key): bool => \is_array($key) && [] !== $key); + + return [] !== $nonEmptyKeys; + } +} diff --git a/src/Server/Transport/Http/OAuth/JwksProviderInterface.php b/src/Server/Transport/Http/OAuth/JwksProviderInterface.php new file mode 100644 index 00000000..f33ac658 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwksProviderInterface.php @@ -0,0 +1,28 @@ + + */ +interface JwksProviderInterface +{ + /** + * @param string $issuer authorization server issuer URL + * @param string|null $jwksUri Optional explicit JWKS URI. If null, implementation may resolve via discovery. + * + * @return array + */ + public function getJwks(string $issuer, ?string $jwksUri = null): array; +} diff --git a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php new file mode 100644 index 00000000..6291314f --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php @@ -0,0 +1,218 @@ + + */ +class JwtTokenValidator implements AuthorizationTokenValidatorInterface +{ + /** + * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") + * @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 list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) + * @param string $scopeClaim Claim name for scopes (default: "scope") + */ + public function __construct( + private readonly string|array $issuer, + private readonly string|array $audience, + private readonly JwksProviderInterface $jwksProvider, + private readonly ?string $jwksUri = null, + private readonly array $algorithms = ['RS256', 'RS384', 'RS512'], + private readonly string $scopeClaim = 'scope', + ) { + } + + public function validate(string $accessToken): AuthorizationResult + { + try { + $keys = $this->getJwks(); + $decoded = JWT::decode($accessToken, $keys); + /** @var array $claims */ + $claims = (array) $decoded; + + // Validate issuer + if (!$this->validateIssuer($claims)) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token issuer mismatch.' + ); + } + + // Validate audience + if (!$this->validateAudience($claims)) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token audience mismatch.' + ); + } + + // Extract scopes + $scopes = $this->extractScopes($claims); + + // Build attributes to attach to request + $attributes = [ + 'oauth.claims' => $claims, + 'oauth.scopes' => $scopes, + ]; + + // Add common claims as individual attributes + if (isset($claims['sub'])) { + $attributes['oauth.subject'] = $claims['sub']; + } + + if (isset($claims['client_id'])) { + $attributes['oauth.client_id'] = $claims['client_id']; + } + + // Add azp (authorized party) for OIDC tokens + if (isset($claims['azp'])) { + $attributes['oauth.authorized_party'] = $claims['azp']; + } + + return AuthorizationResult::allow($attributes); + } catch (ExpiredException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } catch (SignatureInvalidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.'); + } catch (BeforeValidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); + } catch (\UnexpectedValueException|\DomainException $e) { + return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage()); + } catch (\Throwable) { + return AuthorizationResult::unauthorized('invalid_token', 'Token validation error.'); + } + } + + /** + * Validates a token has the required scopes. + * + * Use this after validation to check specific scope requirements. + * + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation + * + * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise + */ + public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult + { + if (!$result->isAllowed()) { + return $result; + } + + $tokenScopes = $result->getAttributes()['oauth.scopes'] ?? []; + + if (!\is_array($tokenScopes)) { + $tokenScopes = []; + } + + foreach ($requiredScopes as $required) { + if (!\in_array($required, $tokenScopes, true)) { + return AuthorizationResult::forbidden( + 'insufficient_scope', + \sprintf('Required scope: %s', $required), + $requiredScopes + ); + } + } + + return $result; + } + + /** + * @return array + */ + private function getJwks(): array + { + $issuer = \is_array($this->issuer) ? $this->issuer[0] : $this->issuer; + $jwksData = $this->jwksProvider->getJwks($issuer, $this->jwksUri); + + /* @var array */ + return JWK::parseKeySet($jwksData, $this->algorithms[0]); + } + + /** + * @param array $claims + */ + private function validateAudience(array $claims): bool + { + if (!isset($claims['aud'])) { + return false; + } + + $tokenAudiences = \is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; + $expectedAudiences = \is_array($this->audience) ? $this->audience : [$this->audience]; + + foreach ($expectedAudiences as $expected) { + if (\in_array($expected, $tokenAudiences, true)) { + return true; + } + } + + return false; + } + + /** + * @param array $claims + */ + private function validateIssuer(array $claims): bool + { + if (!isset($claims['iss'])) { + return false; + } + + $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; + + return \in_array($claims['iss'], $expectedIssuers, true); + } + + /** + * @param array $claims + * + * @return list + */ + private function extractScopes(array $claims): array + { + if (!isset($claims[$this->scopeClaim])) { + return []; + } + + $scopeValue = $claims[$this->scopeClaim]; + + if (\is_array($scopeValue)) { + return array_values(array_filter($scopeValue, 'is_string')); + } + + if (\is_string($scopeValue)) { + return array_values(array_filter(explode(' ', $scopeValue))); + } + + return []; + } +} diff --git a/src/Server/Transport/Middleware/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php similarity index 70% rename from src/Server/Transport/Middleware/OidcDiscovery.php rename to src/Server/Transport/Http/OAuth/OidcDiscovery.php index e8186016..dbff831c 100644 --- a/src/Server/Transport/Middleware/OidcDiscovery.php +++ b/src/Server/Transport/Http/OAuth/OidcDiscovery.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\Transport\Middleware; +namespace Mcp\Server\Transport\Http\OAuth; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; @@ -30,61 +30,31 @@ * * @author Volodymyr Panivko */ -class OidcDiscovery +class OidcDiscovery implements OidcDiscoveryInterface { private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; + private OidcDiscoveryMetadataPolicyInterface $metadataPolicy; private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; /** - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) - * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) - * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param OidcDiscoveryMetadataPolicyInterface|null $metadataPolicy Metadata validation policy */ public function __construct( ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, private readonly ?CacheInterface $cache = null, private readonly int $cacheTtl = 3600, + ?OidcDiscoveryMetadataPolicyInterface $metadataPolicy = null, ) { $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); - } - - /** - * Discovers authorization server metadata from the issuer URL. - * - * Tries endpoints in priority order per RFC 8414 and OpenID Connect Discovery: - * 1. OAuth 2.0 path insertion: /.well-known/oauth-authorization-server/{path} - * 2. OIDC path insertion: /.well-known/openid-configuration/{path} - * 3. OIDC path appending: {path}/.well-known/openid-configuration - * - * @param string $issuer The issuer URL (e.g., "https://auth.example.com/realms/mcp") - * - * @return array The authorization server metadata - * - * @throws RuntimeException If discovery fails - */ - public function discover(string $issuer): array - { - $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $issuer); - - if (null !== $this->cache) { - $cached = $this->cache->get($cacheKey); - if (\is_array($cached)) { - return $cached; - } - } - - $metadata = $this->fetchMetadata($issuer); - - if (null !== $this->cache) { - $this->cache->set($cacheKey, $metadata, $this->cacheTtl); - } - - return $metadata; + $this->metadataPolicy = $metadataPolicy ?? new StrictOidcDiscoveryMetadataPolicy(); } /** @@ -94,106 +64,80 @@ public function discover(string $issuer): array * * @return string The JWKS URI * - * @throws RuntimeException If JWKS URI is not found in metadata + * @throws RuntimeException If discover fails */ public function getJwksUri(string $issuer): string { $metadata = $this->discover($issuer); - if (!isset($metadata['jwks_uri']) || !\is_string($metadata['jwks_uri'])) { - throw new RuntimeException('Authorization server metadata does not contain jwks_uri.'); - } - return $metadata['jwks_uri']; } /** - * Fetches JWKS (JSON Web Key Set) from the authorization server. + * Gets the token endpoint from the authorization server metadata. * * @param string $issuer The issuer URL * - * @return array The JWKS + * @return string The token endpoint URL * - * @throws RuntimeException If fetching fails + * @throws RuntimeException If discover fails */ - public function fetchJwks(string $issuer): array + public function getTokenEndpoint(string $issuer): string { - $jwksUri = $this->getJwksUri($issuer); - - $cacheKey = self::CACHE_KEY_PREFIX.'jwks_'.hash('sha256', $jwksUri); - - if (null !== $this->cache) { - $cached = $this->cache->get($cacheKey); - if (\is_array($cached)) { - return $cached; - } - } - - $jwks = $this->fetchJson($jwksUri); - - if (null !== $this->cache) { - $this->cache->set($cacheKey, $jwks, $this->cacheTtl); - } + $metadata = $this->discover($issuer); - return $jwks; + return $metadata['token_endpoint']; } /** - * Checks if the authorization server supports PKCE. + * Gets the authorization endpoint from the authorization server metadata. * * @param string $issuer The issuer URL * - * @return bool True if PKCE is supported (code_challenge_methods_supported includes S256) + * @return string The authorization endpoint URL + * + * @throws RuntimeException If discover fails */ - public function supportsPkce(string $issuer): bool + public function getAuthorizationEndpoint(string $issuer): string { $metadata = $this->discover($issuer); - if (!isset($metadata['code_challenge_methods_supported']) || !\is_array($metadata['code_challenge_methods_supported'])) { - return false; - } - - return \in_array('S256', $metadata['code_challenge_methods_supported'], true); + return $metadata['authorization_endpoint']; } /** - * Gets the token endpoint from the authorization server metadata. + * Discovers authorization server metadata from the issuer URL. * - * @param string $issuer The issuer URL + * Tries endpoints in priority order per RFC 8414 and OpenID Connect Discovery: + * 1. OAuth 2.0 path insertion: /.well-known/oauth-authorization-server/{path} + * 2. OIDC path insertion: /.well-known/openid-configuration/{path} + * 3. OIDC path appending: {path}/.well-known/openid-configuration * - * @return string The token endpoint URL + * @param string $issuer The issuer URL (e.g., "https://auth.example.com/realms/mcp") + * + * @return array The authorization server metadata * - * @throws RuntimeException If token endpoint is not found + * @throws RuntimeException If discovery fails */ - public function getTokenEndpoint(string $issuer): string + public function discover(string $issuer): array { - $metadata = $this->discover($issuer); + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $issuer); - if (!isset($metadata['token_endpoint']) || !\is_string($metadata['token_endpoint'])) { - throw new RuntimeException('Authorization server metadata does not contain token_endpoint.'); + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if ($this->metadataPolicy->isValid($cached)) { + /* @var array $cached */ + return $cached; + } } - return $metadata['token_endpoint']; - } - - /** - * Gets the authorization endpoint from the authorization server metadata. - * - * @param string $issuer The issuer URL - * - * @return string The authorization endpoint URL - * - * @throws RuntimeException If authorization endpoint is not found - */ - public function getAuthorizationEndpoint(string $issuer): string - { - $metadata = $this->discover($issuer); + $metadata = $this->fetchMetadata($issuer); - if (!isset($metadata['authorization_endpoint']) || !\is_string($metadata['authorization_endpoint'])) { - throw new RuntimeException('Authorization server metadata does not contain authorization_endpoint.'); + if (null !== $this->cache) { + $this->cache->set($cacheKey, $metadata, $this->cacheTtl); } - return $metadata['authorization_endpoint']; + return $metadata; } /** @@ -237,6 +181,9 @@ private function fetchMetadata(string $issuer): array foreach ($discoveryUrls as $url) { try { $metadata = $this->fetchJson($url); + if (!$this->metadataPolicy->isValid($metadata)) { + throw new RuntimeException(\sprintf('OIDC discovery response from %s has invalid format.', $url)); + } // Validate issuer claim matches if (isset($metadata['issuer']) && $metadata['issuer'] !== $issuer) { @@ -261,7 +208,11 @@ private function fetchJson(string $url): array $request = $this->requestFactory->createRequest('GET', $url) ->withHeader('Accept', 'application/json'); - $response = $this->httpClient->sendRequest($request); + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + throw new RuntimeException(\sprintf('HTTP request to %s failed: %s', $url, $e->getMessage()), 0, $e); + } if ($response->getStatusCode() >= 400) { throw new RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); diff --git a/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php b/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php new file mode 100644 index 00000000..aeabf0bb --- /dev/null +++ b/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php @@ -0,0 +1,31 @@ + + */ +interface OidcDiscoveryInterface +{ + /** + * @return array + */ + public function discover(string $issuer): array; + + public function getAuthorizationEndpoint(string $issuer): string; + + public function getTokenEndpoint(string $issuer): string; + + public function getJwksUri(string $issuer): string; +} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php b/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php new file mode 100644 index 00000000..edf94c48 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php @@ -0,0 +1,22 @@ + + */ +interface OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool; +} diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php new file mode 100644 index 00000000..dd96982b --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php @@ -0,0 +1,245 @@ + + */ +final class ProtectedResourceMetadata implements \JsonSerializable +{ + public const DEFAULT_METADATA_PATH = '/.well-known/oauth-protected-resource'; + + private const LOCALIZED_HUMAN_READABLE_FIELD_PATTERN = '/^(resource_name|resource_documentation|resource_policy_uri|resource_tos_uri)#[A-Za-z0-9-]+$/'; + + /** @var list */ + private array $authorizationServers; + + /** @var list|null */ + private ?array $scopesSupported; + + /** @var list */ + private array $metadataPaths; + + /** @var array */ + private array $localizedHumanReadable; + + /** @var array */ + private array $extra; + + private ?string $resource; + private ?string $resourceName; + private ?string $resourceDocumentation; + private ?string $resourcePolicyUri; + private ?string $resourceTosUri; + + /** + * @param list $authorizationServers + * @param list|null $scopesSupported + * @param array $localizedHumanReadable Locale-specific values, e.g. resource_name#en => "My Resource" + * @param array $extra Additional RFC 9728 metadata fields + * @param list $metadataPaths + */ + public function __construct( + array $authorizationServers, + ?array $scopesSupported = null, + ?string $resource = null, + ?string $resourceName = null, + ?string $resourceDocumentation = null, + ?string $resourcePolicyUri = null, + ?string $resourceTosUri = null, + array $localizedHumanReadable = [], + array $extra = [], + array $metadataPaths = [self::DEFAULT_METADATA_PATH], + ) { + $this->authorizationServers = $this->normalizeStringList($authorizationServers, 'authorizationServers'); + if ([] === $this->authorizationServers) { + throw new InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); + } + + $normalizedScopes = $this->normalizeStringList($scopesSupported ?? [], 'scopesSupported'); + $this->scopesSupported = [] === $normalizedScopes ? null : $normalizedScopes; + + $this->resource = $this->normalizeNullableString($resource); + $this->resourceName = $this->normalizeNullableString($resourceName); + $this->resourceDocumentation = $this->normalizeNullableString($resourceDocumentation); + $this->resourcePolicyUri = $this->normalizeNullableString($resourcePolicyUri); + $this->resourceTosUri = $this->normalizeNullableString($resourceTosUri); + $this->localizedHumanReadable = $this->normalizeLocalizedHumanReadable($localizedHumanReadable); + $this->extra = $extra; + + $this->metadataPaths = $this->normalizePaths($metadataPaths); + if ([] === $this->metadataPaths) { + throw new InvalidArgumentException('Protected resource metadata requires at least one metadata path.'); + } + } + + /** + * @return list + */ + public function getMetadataPaths(): array + { + return $this->metadataPaths; + } + + public function getPrimaryMetadataPath(): string + { + return $this->metadataPaths[0]; + } + + /** + * @return list|null + */ + public function getScopesSupported(): ?array + { + return $this->scopesSupported; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'authorization_servers' => $this->authorizationServers, + ]; + + if (null !== $this->scopesSupported) { + $data['scopes_supported'] = $this->scopesSupported; + } + + if (null !== $this->resource) { + $data['resource'] = $this->resource; + } + + if (null !== $this->resourceName) { + $data['resource_name'] = $this->resourceName; + } + + if (null !== $this->resourceDocumentation) { + $data['resource_documentation'] = $this->resourceDocumentation; + } + + if (null !== $this->resourcePolicyUri) { + $data['resource_policy_uri'] = $this->resourcePolicyUri; + } + + if (null !== $this->resourceTosUri) { + $data['resource_tos_uri'] = $this->resourceTosUri; + } + + foreach ($this->localizedHumanReadable as $key => $value) { + $data[$key] = $value; + } + + return array_merge($this->extra, $data); + } + + /** + * @param list $values + * + * @return list + */ + private function normalizeStringList(array $values, string $parameterName): array + { + $normalized = []; + + foreach ($values as $value) { + if (!\is_string($value)) { + throw new InvalidArgumentException(\sprintf('Protected resource metadata parameter "%s" must contain strings.', $parameterName)); + } + + $value = trim($value); + if ('' === $value) { + continue; + } + + $normalized[] = $value; + } + + return array_values(array_unique($normalized)); + } + + private function normalizeNullableString(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } + + /** + * @param list $paths + * + * @return list + */ + private function normalizePaths(array $paths): array + { + $normalized = []; + + foreach ($paths as $path) { + if (!\is_string($path)) { + throw new InvalidArgumentException('Protected resource metadata paths must be strings.'); + } + + $path = trim($path); + if ('' === $path) { + continue; + } + + if ('/' !== $path[0]) { + $path = '/'.$path; + } + + $normalized[] = $path; + } + + return array_values(array_unique($normalized)); + } + + /** + * @param array $localizedHumanReadable + * + * @return array + */ + private function normalizeLocalizedHumanReadable(array $localizedHumanReadable): array + { + $normalized = []; + + foreach ($localizedHumanReadable as $field => $value) { + if (!\is_string($field) || !preg_match(self::LOCALIZED_HUMAN_READABLE_FIELD_PATTERN, $field)) { + throw new InvalidArgumentException(\sprintf('Invalid localized human-readable field: "%s".', (string) $field)); + } + + if (!\is_string($value)) { + throw new InvalidArgumentException(\sprintf('Localized human-readable value for "%s" must be a string.', $field)); + } + + $value = trim($value); + if ('' === $value) { + continue; + } + + $normalized[$field] = $value; + } + + return $normalized; + } +} diff --git a/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php new file mode 100644 index 00000000..b89a3f8a --- /dev/null +++ b/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php @@ -0,0 +1,48 @@ + + */ +final class StrictOidcDiscoveryMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool + { + if (!\is_array($metadata) + || !isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) + || !\is_string($metadata['authorization_endpoint']) + || '' === trim($metadata['authorization_endpoint']) + || !\is_string($metadata['token_endpoint']) + || '' === trim($metadata['token_endpoint']) + || !\is_string($metadata['jwks_uri']) + || '' === trim($metadata['jwks_uri']) + || !isset($metadata['code_challenge_methods_supported']) + ) { + return false; + } + + if (!\is_array($metadata['code_challenge_methods_supported']) || [] === $metadata['code_challenge_methods_supported']) { + return false; + } + + foreach ($metadata['code_challenge_methods_supported'] as $method) { + if (!\is_string($method) || '' === trim($method)) { + return false; + } + } + + return true; + } +} diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php deleted file mode 100644 index 9f81be87..00000000 --- a/src/Server/Transport/Middleware/JwtTokenValidator.php +++ /dev/null @@ -1,391 +0,0 @@ - - */ -class JwtTokenValidator implements AuthorizationTokenValidatorInterface -{ - private ClientInterface $httpClient; - private RequestFactoryInterface $requestFactory; - private ?OidcDiscovery $discovery = null; - - private const CACHE_KEY_PREFIX = 'mcp_jwt_jwks_'; - - /** - * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") For Microsoft Entra ID, you may need to provide both v1.0 and v2.0 issuers - * @param string|list $audience Expected audience(s) for the token - * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) - * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param CacheInterface|null $cache PSR-16 cache for JWKS (optional) - * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) - * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) - * @param string $scopeClaim Claim name for scopes (default: "scope") - */ - public function __construct( - private readonly string|array $issuer, - private readonly string|array $audience, - private readonly ?string $jwksUri = null, - ?ClientInterface $httpClient = null, - ?RequestFactoryInterface $requestFactory = null, - private readonly ?CacheInterface $cache = null, - private readonly int $cacheTtl = 3600, - private readonly array $algorithms = ['RS256', 'RS384', 'RS512'], - private readonly string $scopeClaim = 'scope', - ) { - $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); - $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); - } - - public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult - { - // Decode header to see key ID - $parts = explode('.', $accessToken); - $header = null; - if (\count($parts) >= 2) { - $header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true); - } - - // Microsoft Graph tokens have 'nonce' in header and cannot be verified externally - // These are opaque tokens meant only for Microsoft Graph API - if (isset($header['nonce'])) { - return $this->validateGraphToken($accessToken, $parts); - } - - try { - $keys = $this->getJwks(); - $decoded = JWT::decode($accessToken, $keys); - /** @var array $claims */ - $claims = (array) $decoded; - - // Validate issuer - if (!$this->validateIssuer($claims)) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token issuer mismatch.' - ); - } - - // Validate audience - if (!$this->validateAudience($claims)) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token audience mismatch.' - ); - } - - // Extract scopes - $scopes = $this->extractScopes($claims); - - // Build attributes to attach to request - $attributes = [ - 'oauth.claims' => $claims, - 'oauth.scopes' => $scopes, - ]; - - // Add common claims as individual attributes - if (isset($claims['sub'])) { - $attributes['oauth.subject'] = $claims['sub']; - } - - if (isset($claims['client_id'])) { - $attributes['oauth.client_id'] = $claims['client_id']; - } - - // Add azp (authorized party) for OIDC tokens - if (isset($claims['azp'])) { - $attributes['oauth.authorized_party'] = $claims['azp']; - } - - return AuthorizationResult::allow($attributes); - } catch (ExpiredException) { - return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); - } catch (SignatureInvalidException) { - return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.'); - } catch (BeforeValidException) { - return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); - } catch (\UnexpectedValueException|\DomainException $e) { - return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage()); - } catch (\Throwable) { - return AuthorizationResult::unauthorized('invalid_token', 'Token validation error.'); - } - } - - /** - * Validates Microsoft Graph tokens that cannot be signature-verified externally. - * - * Microsoft Graph access tokens contain a 'nonce' in the header and use a special - * format where the signature cannot be verified by third parties. These tokens are - * meant only for Microsoft Graph API consumption. - * - * This method performs claim-based validation without signature verification. - * - * @param string $accessToken The JWT access token - * @param array $parts Token parts (header, payload, signature) - */ - private function validateGraphToken(string $accessToken, array $parts): AuthorizationResult - { - if (\count($parts) < 2) { - return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); - } - - try { - $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); - if (null === $payload) { - return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.'); - } - - // Validate expiration - if (isset($payload['exp']) && $payload['exp'] < time()) { - return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); - } - - // Validate not before - if (isset($payload['nbf']) && $payload['nbf'] > time() + 60) { - return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); - } - - // For Graph tokens, we accept them if they came from Microsoft - // The issuer should be Microsoft's STS - $issuer = $payload['iss'] ?? ''; - if (!str_contains($issuer, 'sts.windows.net') && !str_contains($issuer, 'login.microsoftonline.com')) { - return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.'); - } - - // Extract scopes - $scopes = $this->extractScopes($payload); - - // Build attributes - $attributes = [ - 'oauth.claims' => $payload, - 'oauth.scopes' => $scopes, - 'oauth.graph_token' => true, // Mark as Graph token - ]; - - if (isset($payload['sub'])) { - $attributes['oauth.subject'] = $payload['sub']; - } - - if (isset($payload['oid'])) { - $attributes['oauth.object_id'] = $payload['oid']; - } - - if (isset($payload['name'])) { - $attributes['oauth.name'] = $payload['name']; - } - - return AuthorizationResult::allow($attributes); - } catch (\Throwable $e) { - return AuthorizationResult::unauthorized('invalid_token', 'Graph token validation failed.'); - } - } - - /** - * Validates a token has the required scopes. - * - * Use this after validation to check specific scope requirements. - * - * @param AuthorizationResult $result The result from validate() - * @param list $requiredScopes Scopes required for this operation - * - * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise - */ - public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult - { - if (!$result->isAllowed()) { - return $result; - } - - $tokenScopes = $result->getAttributes()['oauth.scopes'] ?? []; - - if (!\is_array($tokenScopes)) { - $tokenScopes = []; - } - - foreach ($requiredScopes as $required) { - if (!\in_array($required, $tokenScopes, true)) { - return AuthorizationResult::forbidden( - 'insufficient_scope', - \sprintf('Required scope: %s', $required), - $requiredScopes - ); - } - } - - return $result; - } - - /** - * @return array - */ - private function getJwks(): array - { - $jwksUri = $this->resolveJwksUri(); - $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri); - - $jwksData = null; - - if (null !== $this->cache) { - $cached = $this->cache->get($cacheKey); - if (\is_array($cached)) { - /** @var array $cached */ - $jwksData = $cached; - } - } - - if (null === $jwksData) { - $jwksData = $this->fetchJwks($jwksUri); - - if (null !== $this->cache) { - $this->cache->set($cacheKey, $jwksData, $this->cacheTtl); - } - } - - /* @var array */ - return JWK::parseKeySet($jwksData, $this->algorithms[0]); - } - - private function resolveJwksUri(): string - { - if (null !== $this->jwksUri) { - return $this->jwksUri; - } - - // Auto-discover from first issuer - if (null === $this->discovery) { - $this->discovery = new OidcDiscovery( - $this->httpClient, - $this->requestFactory, - $this->cache, - $this->cacheTtl - ); - } - - $issuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; - - return $this->discovery->getJwksUri($issuers[0]); - } - - /** - * @param array $claims - */ - private function validateIssuer(array $claims): bool - { - if (!isset($claims['iss'])) { - return false; - } - - $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; - - return \in_array($claims['iss'], $expectedIssuers, true); - } - - /** - * @return array - */ - private function fetchJwks(string $jwksUri): array - { - $request = $this->requestFactory->createRequest('GET', $jwksUri) - ->withHeader('Accept', 'application/json'); - - $response = $this->httpClient->sendRequest($request); - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode())); - } - - $body = $response->getBody()->__toString(); - - try { - $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); - } - - if (!\is_array($data) || !isset($data['keys'])) { - throw new RuntimeException('Invalid JWKS format: missing "keys" array.'); - } - - /* @var array $data */ - return $data; - } - - /** - * @param array $claims - */ - private function validateAudience(array $claims): bool - { - if (!isset($claims['aud'])) { - return false; - } - - $tokenAudiences = \is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; - $expectedAudiences = \is_array($this->audience) ? $this->audience : [$this->audience]; - - foreach ($expectedAudiences as $expected) { - if (\in_array($expected, $tokenAudiences, true)) { - return true; - } - } - - return false; - } - - /** - * @param array $claims - * - * @return list - */ - private function extractScopes(array $claims): array - { - if (!isset($claims[$this->scopeClaim])) { - return []; - } - - $scopeValue = $claims[$this->scopeClaim]; - - if (\is_array($scopeValue)) { - return array_values(array_filter($scopeValue, 'is_string')); - } - - if (\is_string($scopeValue)) { - return array_values(array_filter(explode(' ', $scopeValue))); - } - - return []; - } -} diff --git a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php deleted file mode 100644 index 0665e714..00000000 --- a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php +++ /dev/null @@ -1,90 +0,0 @@ - - */ -class ProtectedResourceMetadata -{ - /** - * @param list $authorizationServers URLs of authorization servers that can issue tokens for this resource - * @param list|null $scopesSupported OAuth scopes supported by this resource - * @param string|null $resource The resource identifier (typically the resource's URL) - * @param array $extra Additional metadata fields - */ - public function __construct( - private readonly array $authorizationServers, - private readonly ?array $scopesSupported = null, - private readonly ?string $resource = null, - private readonly array $extra = [], - ) { - if (empty($authorizationServers)) { - throw new InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); - } - } - - /** - * @return list - */ - public function getAuthorizationServers(): array - { - return $this->authorizationServers; - } - - /** - * @return list|null - */ - public function getScopesSupported(): ?array - { - return $this->scopesSupported; - } - - public function getResource(): ?string - { - return $this->resource; - } - - /** - * @return array - */ - public function toArray(): array - { - $data = [ - 'authorization_servers' => array_values($this->authorizationServers), - ]; - - if (null !== $this->scopesSupported) { - $data['scopes_supported'] = array_values($this->scopesSupported); - } - - if (null !== $this->resource) { - $data['resource'] = $this->resource; - } - - return array_merge($this->extra, $data); - } - - public function toJson(): string - { - return json_encode($this->toArray(), \JSON_THROW_ON_ERROR); - } -} diff --git a/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php similarity index 59% rename from tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php rename to tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php index 835d6443..bd05af13 100644 --- a/tests/Unit/Server/Transport/Middleware/AuthorizationMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php @@ -9,13 +9,13 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\Transport\Middleware; +namespace Mcp\Tests\Unit\Server\Transport\Http\Middleware; use Mcp\Exception\RuntimeException; -use Mcp\Server\Transport\Middleware\AuthorizationMiddleware; -use Mcp\Server\Transport\Middleware\AuthorizationResult; -use Mcp\Server\Transport\Middleware\AuthorizationTokenValidatorInterface; -use Mcp\Server\Transport\Middleware\ProtectedResourceMetadata; +use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; +use Mcp\Server\Transport\Http\OAuth\AuthorizationResult; +use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface; +use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -24,30 +24,32 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Tests AuthorizationMiddleware behavior for token validation and challenges. + * + * @author Volodymyr Panivko + */ class AuthorizationMiddlewareTest extends TestCase { #[TestDox('missing Authorization header returns 401 with metadata and scope guidance')] public function testMissingAuthorizationReturns401(): void { $factory = new Psr17Factory(); - $metadata = new ProtectedResourceMetadata(['https://auth.example.com'], ['mcp:read']); + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read'], + ); $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + public function validate(string $accessToken): AuthorizationResult { throw new RuntimeException('Validator should not be called without a token.'); } }; $middleware = new AuthorizationMiddleware( - $metadata, - $validator, - $factory, - $factory, - ['/.well-known/oauth-protected-resource'], - 'https://mcp.example.com/.well-known/oauth-protected-resource', - static function (): array { - return ['mcp:read']; - }, + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, ); $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); @@ -78,21 +80,18 @@ public function handle(ServerRequestInterface $request): ResponseInterface public function testMalformedAuthorizationReturns400(): void { $factory = new Psr17Factory(); - $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + public function validate(string $accessToken): AuthorizationResult { return AuthorizationResult::allow(); } }; $middleware = new AuthorizationMiddleware( - $metadata, - $validator, - $factory, - $factory, - ['/.well-known/oauth-protected-resource'], - 'https://mcp.example.com/.well-known/oauth-protected-resource', + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, ); $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') @@ -119,21 +118,18 @@ public function handle(ServerRequestInterface $request): ResponseInterface public function testInsufficientScopeReturns403(): void { $factory = new Psr17Factory(); - $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + public function validate(string $accessToken): AuthorizationResult { return AuthorizationResult::forbidden('insufficient_scope', 'Need more scopes.', ['mcp:write']); } }; $middleware = new AuthorizationMiddleware( - $metadata, - $validator, - $factory, - $factory, - ['/.well-known/oauth-protected-resource'], - 'https://mcp.example.com/.well-known/oauth-protected-resource', + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, ); $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') @@ -158,34 +154,73 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->assertStringContainsString('scope="mcp:write"', $header); } - #[TestDox('metadata endpoint returns protected resource metadata JSON')] - public function testMetadataEndpointReturnsJson(): void + #[TestDox('metadata scopes are used in challenge when result has no scopes')] + public function testMetadataScopesAreUsedWhenResultHasNoScopes(): void { $factory = new Psr17Factory(); - $metadata = new ProtectedResourceMetadata( - ['https://auth.example.com'], - ['mcp:read', 'mcp:write'], + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], ); $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + public function validate(string $accessToken): AuthorizationResult { - return AuthorizationResult::allow(); + throw new RuntimeException('Validator should not be called without a token.'); } }; $middleware = new AuthorizationMiddleware( - $metadata, - $validator, - $factory, - $factory, - ['/.well-known/oauth-protected-resource'], + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, ); - $request = $factory->createServerRequest( - 'GET', - 'https://mcp.example.com/.well-known/oauth-protected-resource', + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + $header = $response->getHeaderLine('WWW-Authenticate'); + + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + $header, + ); + $this->assertStringContainsString('scope="openid profile"', $header); + } + + #[TestDox('resource metadata object path and scopes are reflected in challenge')] + public function testResourceMetadataObjectProvidesMetadataAndScopes(): void + { + $factory = new Psr17Factory(); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + throw new RuntimeException('Validator should not be called without a token.'); + } + }; + + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], + metadataPaths: ['/oauth/resource-meta'], ); + $middleware = new AuthorizationMiddleware( + validator: $validator, + responseFactory: $factory, + resourceMetadata: $resourceMetadata, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); $handler = new class($factory) implements RequestHandlerInterface { public function __construct(private ResponseFactoryInterface $factory) { @@ -198,32 +233,32 @@ public function handle(ServerRequestInterface $request): ResponseInterface }; $response = $middleware->process($request, $handler); + $header = $response->getHeaderLine('WWW-Authenticate'); - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - - $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); - $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); + $this->assertSame(401, $response->getStatusCode()); + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/oauth/resource-meta"', + $header, + ); + $this->assertStringContainsString('scope="openid profile"', $header); } #[TestDox('authorized requests reach the handler with attributes applied')] public function testAllowedRequestPassesAttributes(): void { $factory = new Psr17Factory(); - $metadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); $validator = new class implements AuthorizationTokenValidatorInterface { - public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + public function validate(string $accessToken): AuthorizationResult { return AuthorizationResult::allow(['subject' => 'user-1']); } }; $middleware = new AuthorizationMiddleware( - $metadata, - $validator, - $factory, - $factory, + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, ); $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php new file mode 100644 index 00000000..50017ccf --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php @@ -0,0 +1,220 @@ + + */ +class OAuthProxyMiddlewareTest extends TestCase +{ + #[TestDox('metadata endpoint returns local oauth metadata with upstream capabilities')] + public function testMetadataEndpointReturnsLocalMetadata(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'response_types_supported' => ['code'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => ['S256'], + 'scopes_supported' => ['openid', 'profile'], + 'token_endpoint_auth_methods_supported' => ['client_secret_post'], + ]); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('https://login.example.com/tenant', $payload['issuer']); + $this->assertSame('http://localhost:8000/authorize', $payload['authorization_endpoint']); + $this->assertSame('http://localhost:8000/token', $payload['token_endpoint']); + $this->assertSame(['openid', 'profile'], $payload['scopes_supported']); + $this->assertSame('https://login.example.com/discovery/v2.0/keys', $payload['jwks_uri']); + } + + #[TestDox('authorize endpoint redirects to upstream authorization endpoint preserving query')] + public function testAuthorizeEndpointRedirectsToUpstream(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getAuthorizationEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/authorize'); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest( + 'GET', + 'http://localhost:8000/authorize?client_id=test-client&scope=openid%20profile&code_challenge=abc', + ); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame( + 'https://login.example.com/oauth2/v2.0/authorize?client_id=test-client&scope=openid%20profile&code_challenge=abc', + $response->getHeaderLine('Location'), + ); + } + + #[TestDox('token endpoint proxies request and injects client secret')] + public function testTokenEndpointProxiesRequestAndInjectsClientSecret(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getTokenEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/token'); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); + $this->assertSame('Basic Zm9vOmJhcg==', $request->getHeaderLine('Authorization')); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + + parse_str($request->getBody()->__toString(), $params); + $this->assertSame('authorization_code', $params['grant_type'] ?? null); + $this->assertSame('abc123', $params['code'] ?? null); + $this->assertSame('secret-value', $params['client_secret'] ?? null); + + return $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{"access_token":"token-1"}')); + }); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + clientSecret: 'secret-value', + discovery: $discovery, + httpClient: $httpClient, + requestFactory: $factory, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') + ->withHeader('Authorization', 'Basic Zm9vOmJhcg==') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($factory->createStream('grant_type=authorization_code&code=abc123')); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); + } + + #[TestDox('non oauth proxy requests are delegated to next middleware')] + public function testNonOAuthRequestPassesThrough(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->never())->method('discover'); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'http://localhost:8000/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(204); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(204, $response->getStatusCode()); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php new file mode 100644 index 00000000..c66dfb4e --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php @@ -0,0 +1,179 @@ + + */ +class OAuthRequestMetaMiddlewareTest extends TestCase +{ + #[TestDox('oauth request attributes are copied to json-rpc params _meta')] + public function testInjectsOauthAttributesIntoSingleRequest(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.claims', ['sub' => 'user-1']) + ->withAttribute('oauth.scopes', ['openid', 'profile']) + ->withAttribute('oauth.subject', 'user-1') + ->withAttribute('not_oauth', 'ignored'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame(['sub' => 'user-1'], $decoded['params']['_meta']['oauth']['oauth.claims']); + $this->assertSame(['openid', 'profile'], $decoded['params']['_meta']['oauth']['oauth.scopes']); + $this->assertSame('user-1', $decoded['params']['_meta']['oauth']['oauth.subject']); + $this->assertArrayNotHasKey('not_oauth', $decoded['params']['_meta']['oauth']); + } + + #[TestDox('existing _meta is preserved and oauth keys are merged')] + public function testMergesWithExistingMeta(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'trace_id' => 'trace-1', + 'oauth' => [ + 'client_hint' => 'web', + 'oauth.subject' => 'spoofed', + ], + ], + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.subject', 'trusted-user') + ->withAttribute('oauth.scopes', ['mcp.read']); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame('trace-1', $decoded['params']['_meta']['trace_id']); + $this->assertSame('web', $decoded['params']['_meta']['oauth']['client_hint']); + $this->assertSame('trusted-user', $decoded['params']['_meta']['oauth']['oauth.subject']); + $this->assertSame(['mcp.read'], $decoded['params']['_meta']['oauth']['oauth.scopes']); + } + + #[TestDox('oauth request attributes are copied for each batch entry')] + public function testInjectsOauthAttributesIntoBatchRequest(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [], + ], + [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.subject', 'batch-user'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame('batch-user', $decoded[0]['params']['_meta']['oauth']['oauth.subject']); + $this->assertSame('batch-user', $decoded[1]['params']['_meta']['oauth']['oauth.subject']); + } + + #[TestDox('request without oauth attributes passes through unchanged')] + public function testNoOauthAttributesPassThrough(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $body = '{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream($body)); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + + $this->assertSame($body, $response->getBody()->__toString()); + } + + #[TestDox('non post requests pass through unchanged')] + public function testNonPostPassesThrough(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $body = '{"jsonrpc":"2.0","id":1,"method":"ping"}'; + + $request = $factory + ->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream($body)) + ->withAttribute('oauth.subject', 'user-1'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + + $this->assertSame($body, $response->getBody()->__toString()); + } + + private function createEchoHandler(Psr17Factory $factory): RequestHandlerInterface + { + return new class($factory) implements RequestHandlerInterface { + public function __construct(private readonly Psr17Factory $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory + ->createResponse(200) + ->withBody($this->factory->createStream($request->getBody()->__toString())); + } + }; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php new file mode 100644 index 00000000..66e57e04 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php @@ -0,0 +1,125 @@ + + */ +class ProtectedResourceMetadataMiddlewareTest extends TestCase +{ + #[TestDox('default metadata endpoint returns protected resource metadata JSON')] + public function testDefaultMetadataEndpointReturnsJson(): void + { + $factory = new Psr17Factory(); + + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + resource: 'https://mcp.example.com/mcp', + resourceName: 'Example MCP API', + resourceDocumentation: 'https://mcp.example.com/docs', + localizedHumanReadable: [ + 'resource_name#uk' => 'Pryklad MCP API', + ], + ); + + $middleware = new ProtectedResourceMetadataMiddleware( + metadata: $metadata, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest( + 'GET', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); + $this->assertSame('https://mcp.example.com/mcp', $payload['resource']); + $this->assertSame('Example MCP API', $payload['resource_name']); + $this->assertSame('https://mcp.example.com/docs', $payload['resource_documentation']); + $this->assertSame('Pryklad MCP API', $payload['resource_name#uk']); + } + + #[TestDox('non metadata request passes to next middleware')] + public function testNonMetadataRequestPassesThrough(): void + { + $factory = new Psr17Factory(); + + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + ); + + $middleware = new ProtectedResourceMetadataMiddleware( + metadata: $metadata, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(204); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(204, $response->getStatusCode()); + } + + #[TestDox('empty authorization servers are rejected')] + public function testEmptyAuthorizationServersThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires at least one authorization server'); + + new ProtectedResourceMetadata([]); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php new file mode 100644 index 00000000..a668bcb4 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php @@ -0,0 +1,160 @@ + + */ +class JwksProviderTest extends TestCase +{ + #[TestDox('JWKS are loaded from explicit URI')] + public function testGetJwksFromExplicitUri(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $result = $provider->getJwks('https://auth.example.com', $jwksUri); + + $this->assertSame($jwks, $result); + } + + #[TestDox('invalid cached JWKS are ignored and replaced by fetched values')] + public function testInvalidCachedJwksAreIgnored(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->willReturn(['keys' => []]); + $cache->expects($this->once()) + ->method('set'); + + $provider = new JwksProvider( + httpClient: $httpClient, + requestFactory: $factory, + cache: $cache, + ); + + $result = $provider->getJwks('https://auth.example.com', $jwksUri); + + $this->assertSame($jwks, $result); + } + + #[TestDox('discovery is used when explicit JWKS URI is not provided')] + public function testDiscoveryIsUsedWhenUriIsMissing(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getJwksUri') + ->with('https://auth.example.com') + ->willReturn($jwksUri); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + httpClient: $httpClient, + requestFactory: $factory, + discovery: $discovery, + ); + + $result = $provider->getJwks('https://auth.example.com'); + + $this->assertSame($jwks, $result); + } + + #[TestDox('empty keys in fetched JWKS throw RuntimeException')] + public function testEmptyKeysThrow(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode(['keys' => []], \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('expected non-empty "keys" array'); + + $provider->getJwks('https://auth.example.com', $jwksUri); + } +} diff --git a/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php similarity index 73% rename from tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php rename to tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php index 0e62a6c2..09a1c91e 100644 --- a/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php @@ -9,10 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\Transport\Middleware; +namespace Mcp\Tests\Unit\Server\Transport\Http\OAuth; use Firebase\JWT\JWT; -use Mcp\Server\Transport\Middleware\JwtTokenValidator; +use Mcp\Server\Transport\Http\OAuth\JwksProvider; +use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -20,6 +21,11 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +/** + * Tests JwtTokenValidator for signature, claims, and scope extraction. + * + * @author Volodymyr Panivko + */ class JwtTokenValidatorTest extends TestCase { #[TestDox('valid JWT is allowed and claims/scopes are exposed as request attributes')] @@ -39,8 +45,7 @@ public function testValidJwtAllowsAndExposesAttributes(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: $jwksUri, - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -59,8 +64,7 @@ public function testValidJwtAllowsAndExposesAttributes(): void keyId: 'test-kid', ); - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); - $result = $validator->validate($request, $token); + $result = $validator->validate($token); $this->assertTrue($result->isAllowed()); $attributes = $result->getAttributes(); @@ -90,8 +94,7 @@ public function testIssuerMismatchIsUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: $jwksUri, - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -108,8 +111,7 @@ public function testIssuerMismatchIsUnauthorized(): void keyId: 'test-kid', ); - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); - $result = $validator->validate($request, $token); + $result = $validator->validate($token); $this->assertFalse($result->isAllowed()); $this->assertSame(401, $result->getStatusCode()); @@ -134,8 +136,7 @@ public function testAudienceMismatchIsUnauthorized(): void issuer: 'https://auth.example.com', audience: ['mcp-api'], jwksUri: $jwksUri, - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -152,8 +153,7 @@ public function testAudienceMismatchIsUnauthorized(): void keyId: 'test-kid', ); - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); - $result = $validator->validate($request, $token); + $result = $validator->validate($token); $this->assertFalse($result->isAllowed()); $this->assertSame(401, $result->getStatusCode()); @@ -161,49 +161,6 @@ public function testAudienceMismatchIsUnauthorized(): void $this->assertSame('Token audience mismatch.', $result->getErrorDescription()); } - #[TestDox('Graph token (nonce header) is validated by claims without signature verification')] - public function testGraphTokenWithNonceHeaderIsAllowed(): void - { - $factory = new Psr17Factory(); - - // Build a token with a header containing "nonce" to trigger validateGraphToken(). - $header = $this->b64urlEncode(json_encode([ - 'alg' => 'none', - 'typ' => 'JWT', - 'nonce' => 'abc', - ], \JSON_THROW_ON_ERROR)); - - $payload = $this->b64urlEncode(json_encode([ - 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', - 'aud' => 'mcp-api', - 'sub' => 'user-graph', - 'scp' => 'files.read files.write', - 'iat' => time() - 10, - 'exp' => time() + 600, - ], \JSON_THROW_ON_ERROR)); - - $token = $header.'.'.$payload.'.'; - - $validator = new JwtTokenValidator( - issuer: ['https://auth.example.com'], - audience: ['mcp-api'], - jwksUri: 'https://unused.example.com/jwks', - httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), - requestFactory: $factory, - scopeClaim: 'scp', - ); - - $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); - $result = $validator->validate($request, $token); - - $this->assertTrue($result->isAllowed()); - $attributes = $result->getAttributes(); - - $this->assertTrue($attributes['oauth.graph_token']); - $this->assertSame(['files.read', 'files.write'], $attributes['oauth.scopes']); - $this->assertSame('user-graph', $attributes['oauth.subject']); - } - #[TestDox('expired token yields unauthorized invalid_token with expired message')] public function testExpiredTokenIsUnauthorized(): void { @@ -220,8 +177,7 @@ public function testExpiredTokenIsUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -237,7 +193,7 @@ public function testExpiredTokenIsUnauthorized(): void keyId: 'test-kid', ); - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $result = $validator->validate($token); $this->assertFalse($result->isAllowed()); $this->assertSame(401, $result->getStatusCode()); @@ -261,8 +217,7 @@ public function testBeforeValidTokenIsUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -279,7 +234,7 @@ public function testBeforeValidTokenIsUnauthorized(): void keyId: 'test-kid', ); - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $result = $validator->validate($token); $this->assertFalse($result->isAllowed()); $this->assertSame(401, $result->getStatusCode()); @@ -307,8 +262,7 @@ public function testSignatureInvalidIsUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -324,7 +278,7 @@ public function testSignatureInvalidIsUnauthorized(): void keyId: 'test-kid', ); - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $result = $validator->validate($token); $this->assertFalse($result->isAllowed()); $this->assertSame(401, $result->getStatusCode()); @@ -341,14 +295,13 @@ public function testJwksHttpErrorResultsInUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), requestFactory: $factory), ); - // Any token without the Graph nonce will attempt JWKS and fail. + // Unsigned token forces the validator to load JWKS and fail on HTTP 500. $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $result = $validator->validate($token); $this->assertFalse($result->isAllowed()); $this->assertSame('invalid_token', $result->getError()); @@ -370,13 +323,12 @@ public function testInvalidJwksJsonResultsInUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $result = $validator->validate($token); $this->assertFalse($result->isAllowed()); $this->assertSame('invalid_token', $result->getError()); @@ -398,13 +350,12 @@ public function testJwksMissingKeysResultsInUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $result = $validator->validate($token); $this->assertFalse($result->isAllowed()); $this->assertSame('invalid_token', $result->getError()); @@ -427,8 +378,7 @@ public function testRequireScopesForbiddenWhenMissing(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -445,7 +395,7 @@ public function testRequireScopesForbiddenWhenMissing(): void keyId: 'test-kid', ); - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $result = $validator->validate($token); $this->assertTrue($result->isAllowed()); $scoped = $validator->requireScopes($result, ['mcp:read', 'mcp:write']); @@ -471,8 +421,7 @@ public function testRequireScopesPassesWhenPresent(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -489,84 +438,13 @@ public function testRequireScopesPassesWhenPresent(): void keyId: 'test-kid', ); - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); + $result = $validator->validate($token); $this->assertTrue($result->isAllowed()); $scoped = $validator->requireScopes($result, ['mcp:read']); $this->assertTrue($scoped->isAllowed()); } - #[TestDox('Graph token invalid format is unauthorized')] - public function testGraphTokenInvalidFormatIsUnauthorized(): void - { - $factory = new Psr17Factory(); - - $header = $this->b64urlEncode(json_encode([ - 'alg' => 'none', - 'typ' => 'JWT', - 'nonce' => 'abc', - ], \JSON_THROW_ON_ERROR)); - - // Trigger the Graph token path (nonce in header) with an empty payload segment. - // This makes validateGraphToken() run and fail decoding the payload. - $token = $header.'..'; - - $validator = new JwtTokenValidator( - issuer: ['https://auth.example.com'], - audience: ['mcp-api'], - jwksUri: 'https://unused.example.com/jwks', - httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), - requestFactory: $factory, - scopeClaim: 'scp', - ); - - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); - - $this->assertFalse($result->isAllowed()); - $this->assertSame(401, $result->getStatusCode()); - $this->assertSame('invalid_token', $result->getError()); - $this->assertSame('Invalid token payload.', $result->getErrorDescription()); - } - - #[TestDox('Graph token invalid issuer is unauthorized with graph issuer message')] - public function testGraphTokenInvalidIssuerIsUnauthorized(): void - { - $factory = new Psr17Factory(); - - $header = $this->b64urlEncode(json_encode([ - 'alg' => 'none', - 'typ' => 'JWT', - 'nonce' => 'abc', - ], \JSON_THROW_ON_ERROR)); - - $payload = $this->b64urlEncode(json_encode([ - 'iss' => 'https://evil.example.com', - 'aud' => 'mcp-api', - 'sub' => 'user-graph', - 'scp' => 'files.read', - 'iat' => time() - 10, - 'exp' => time() + 600, - ], \JSON_THROW_ON_ERROR)); - - $token = $header.'.'.$payload.'.'; - - $validator = new JwtTokenValidator( - issuer: ['https://auth.example.com'], - audience: ['mcp-api'], - jwksUri: 'https://unused.example.com/jwks', - httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), - requestFactory: $factory, - scopeClaim: 'scp', - ); - - $result = $validator->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $token); - - $this->assertFalse($result->isAllowed()); - $this->assertSame(401, $result->getStatusCode()); - $this->assertSame('invalid_token', $result->getError()); - $this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription()); - } - #[TestDox('extractScopes returns empty array when scope claim is missing or invalid type')] public function testExtractScopesEdgeCases(): void { @@ -584,8 +462,7 @@ public function testExtractScopesEdgeCases(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), ); $tokenMissing = JWT::encode( @@ -601,7 +478,7 @@ public function testExtractScopesEdgeCases(): void keyId: 'test-kid', ); - $resultMissing = $validatorMissing->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $tokenMissing); + $resultMissing = $validatorMissing->validate($tokenMissing); $this->assertTrue($resultMissing->isAllowed()); $this->assertSame([], $resultMissing->getAttributes()['oauth.scopes']); @@ -612,8 +489,7 @@ public function testExtractScopesEdgeCases(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - httpClient: $httpClient2, - requestFactory: $factory, + jwksProvider: new JwksProvider(httpClient: $httpClient2, requestFactory: $factory), ); $tokenInvalid = JWT::encode( @@ -630,7 +506,7 @@ public function testExtractScopesEdgeCases(): void keyId: 'test-kid', ); - $resultInvalid = $validatorInvalid->validate($factory->createServerRequest('GET', 'https://mcp.example.com/mcp'), $tokenInvalid); + $resultInvalid = $validatorInvalid->validate($tokenInvalid); $this->assertTrue($resultInvalid->isAllowed()); $this->assertSame([], $resultInvalid->getAttributes()['oauth.scopes']); } diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php new file mode 100644 index 00000000..14eb54f1 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -0,0 +1,268 @@ + + */ +class OidcDiscoveryTest extends TestCase +{ + #[TestDox('invalid issuer URL throws RuntimeException')] + public function testInvalidIssuerUrlThrows(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $discovery = new OidcDiscovery( + httpClient: $this->createMock(ClientInterface::class), + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid issuer URL'); + $discovery->discover('invalid-issuer'); + } + + #[TestDox('strict discovery rejects metadata without code challenge methods')] + public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $issuer = 'https://auth.example.com'; + $metadataWithoutCodeChallengeMethods = [ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturn($factory->createResponse(200)->withBody( + $factory->createStream(json_encode($metadataWithoutCodeChallengeMethods, \JSON_THROW_ON_ERROR)), + )); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to discover authorization server metadata'); + $discovery->discover($issuer); + } + + #[TestDox('discover falls back to the next metadata URL when first response is invalid')] + public function testDiscoverFallsBackOnInvalidMetadataResponse(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + + $invalidMetadata = [ + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + // token_endpoint is intentionally missing + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + ]; + $validMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $invalidMetadata, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + $payload = 1 === \count($requestedUrls) ? $invalidMetadata : $validMetadata; + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($validMetadata['authorization_endpoint'], $metadata['authorization_endpoint']); + $this->assertSame($validMetadata['token_endpoint'], $metadata['token_endpoint']); + $this->assertSame($validMetadata['jwks_uri'], $metadata['jwks_uri']); + $this->assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant', + $requestedUrls[0], + ); + $this->assertSame( + 'https://auth.example.com/.well-known/openid-configuration/tenant', + $requestedUrls[1], + ); + } + + #[TestDox('valid metadata from cache is returned without HTTP call')] + public function testDiscoverUsesValidCacheWithoutHttpCall(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $cachedMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->willReturn($cachedMetadata); + $cache->expects($this->never())->method('set'); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + cache: $cache, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($cachedMetadata, $metadata); + } + + #[TestDox('discover skips metadata when issuer claim does not match requested issuer')] + public function testDiscoverSkipsIssuerMismatch(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + + $issuerMismatch = [ + 'issuer' => 'https://auth.example.com/other-tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + $validMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $issuerMismatch, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + $payload = 1 === \count($requestedUrls) ? $issuerMismatch : $validMetadata; + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($validMetadata['issuer'], $metadata['issuer']); + $this->assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant', + $requestedUrls[0], + ); + $this->assertSame( + 'https://auth.example.com/.well-known/openid-configuration/tenant', + $requestedUrls[1], + ); + } + + #[TestDox('issuer without path uses standard well-known endpoints')] + public function testIssuerWithoutPathUsesStandardWellKnownEndpoints(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + $validMetadata = [ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + if (1 === \count($requestedUrls)) { + return $factory->createResponse(404); + } + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover('https://auth.example.com'); + + $this->assertSame($validMetadata['jwks_uri'], $metadata['jwks_uri']); + $this->assertSame('https://auth.example.com/.well-known/oauth-authorization-server', $requestedUrls[0]); + $this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]); + } + + private function skipIfPsrHttpClientIsMissing(): void + { + if (!interface_exists(ClientInterface::class)) { + $this->markTestSkipped('psr/http-client is not available in this runtime.'); + } + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php new file mode 100644 index 00000000..fbb51a91 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php @@ -0,0 +1,86 @@ + + */ +class ProtectedResourceMetadataTest extends TestCase +{ + #[TestDox('serializes RFC 9728 metadata including human-readable fields')] + public function testJsonSerializeIncludesHumanReadableFields(): void + { + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], + resource: 'https://api.example.com/mcp', + resourceName: 'Example MCP API', + resourceDocumentation: 'https://api.example.com/docs', + resourcePolicyUri: 'https://api.example.com/policy', + resourceTosUri: 'https://api.example.com/tos', + localizedHumanReadable: [ + 'resource_name#en' => 'Example MCP API', + ], + extra: [ + 'bearer_methods_supported' => ['header'], + ], + metadataPaths: ['.well-known/oauth-protected-resource'], + ); + + $this->assertSame( + [ + 'bearer_methods_supported' => ['header'], + 'authorization_servers' => ['https://auth.example.com'], + 'scopes_supported' => ['openid', 'profile'], + 'resource' => 'https://api.example.com/mcp', + 'resource_name' => 'Example MCP API', + 'resource_documentation' => 'https://api.example.com/docs', + 'resource_policy_uri' => 'https://api.example.com/policy', + 'resource_tos_uri' => 'https://api.example.com/tos', + 'resource_name#en' => 'Example MCP API', + ], + $metadata->jsonSerialize(), + ); + $this->assertSame('/.well-known/oauth-protected-resource', $metadata->getPrimaryMetadataPath()); + $this->assertSame(['openid', 'profile'], $metadata->getScopesSupported()); + } + + #[TestDox('invalid localized human-readable field is rejected')] + public function testInvalidLocalizedHumanReadableFieldThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid localized human-readable field'); + + new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + localizedHumanReadable: [ + 'invalid#en' => 'value', + ], + ); + } + + #[TestDox('empty authorization servers are rejected')] + public function testEmptyAuthorizationServersThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires at least one authorization server'); + + new ProtectedResourceMetadata([]); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php new file mode 100644 index 00000000..3d1e8a7d --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php @@ -0,0 +1,79 @@ + + */ +class StrictOidcDiscoveryMetadataPolicyTest extends TestCase +{ + #[TestDox('metadata without code challenge methods is invalid in strict mode')] + public function testMissingCodeChallengeMethodsIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('valid code challenge methods list is accepted in strict mode')] + public function testValidCodeChallengeMethodsIsAccepted(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('empty code challenge methods list is invalid in strict mode')] + public function testEmptyCodeChallengeMethodsIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => [], + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('non string code challenge method is invalid in strict mode')] + public function testNonStringCodeChallengeMethodIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => ['S256', 123], + ]; + + $this->assertFalse($policy->isValid($metadata)); + } +} From d7650fb4f632eba151fa6c1d1fd89d63f0fe5ffd Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Sun, 22 Feb 2026 11:32:39 +0100 Subject: [PATCH 11/18] Fix QA PHPStan findings in OAuth examples and auth middleware --- examples/server/oauth-keycloak/McpElements.php | 6 ++++++ examples/server/oauth-microsoft/McpElements.php | 8 ++++++++ .../Http/Middleware/AuthorizationMiddleware.php | 10 ++-------- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/examples/server/oauth-keycloak/McpElements.php b/examples/server/oauth-keycloak/McpElements.php index 0a8d08e7..6825f198 100644 --- a/examples/server/oauth-keycloak/McpElements.php +++ b/examples/server/oauth-keycloak/McpElements.php @@ -30,6 +30,8 @@ final class McpElements * Confirms the user is authenticated. * * The fact that this tool executes means the request passed OAuth validation. + * + * @return array */ #[McpTool( name: 'get_auth_status', @@ -65,6 +67,8 @@ public function getAuthStatus(RequestContext $context): array /** * Simulates calling a protected external API. + * + * @return array */ #[McpTool( name: 'call_protected_api', @@ -91,6 +95,8 @@ public function callProtectedApi( /** * Returns the current server time and status. + * + * @return array */ #[McpResource( uri: 'server://status', diff --git a/examples/server/oauth-microsoft/McpElements.php b/examples/server/oauth-microsoft/McpElements.php index a066a726..06eeaa03 100644 --- a/examples/server/oauth-microsoft/McpElements.php +++ b/examples/server/oauth-microsoft/McpElements.php @@ -28,6 +28,8 @@ final class McpElements { /** * Confirms the user is authenticated with Microsoft. + * + * @return array */ #[McpTool( name: 'get_auth_status', @@ -64,6 +66,8 @@ public function getAuthStatus(RequestContext $context): array /** * Simulates calling Microsoft Graph API. + * + * @return array */ #[McpTool( name: 'call_graph_api', @@ -89,6 +93,8 @@ public function callGraphApi( /** * Lists simulated emails. + * + * @return array */ #[McpTool( name: 'list_emails', @@ -109,6 +115,8 @@ public function listEmails(int $count = 5): array /** * Returns the current server status. + * + * @return array */ #[McpResource( uri: 'server://status', diff --git a/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php index 623b7648..45c997e4 100644 --- a/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php +++ b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php @@ -79,14 +79,12 @@ private function buildErrorResponse(ServerRequestInterface $request, Authorizati $response = $this->responseFactory->createResponse($result->getStatusCode()); $header = $this->buildAuthenticateHeader($request, $result); - if (null !== $header) { - $response = $response->withHeader('WWW-Authenticate', $header); - } + $response = $response->withHeader('WWW-Authenticate', $header); return $response; } - private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): ?string + private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): string { $parts = []; @@ -105,10 +103,6 @@ private function buildAuthenticateHeader(ServerRequestInterface $request, Author $parts[] = 'error_description="'.$this->escapeHeaderValue($result->getErrorDescription()).'"'; } - if ([] === $parts) { - return 'Bearer'; - } - return 'Bearer '.implode(', ', $parts); } From 74b16549f83f354620c9107c9553840d5de0d6eb Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Sun, 22 Feb 2026 12:17:52 +0100 Subject: [PATCH 12/18] Require OIDC discovery in JWKS provider and refine OAuth proxy behavior --- examples/server/oauth-keycloak/server.php | 4 +- .../MicrosoftJwtTokenValidator.php | 7 ++ examples/server/oauth-microsoft/README.md | 1 + examples/server/oauth-microsoft/server.php | 9 +- .../Unit/MicrosoftJwtTokenValidatorTest.php | 16 ++-- .../Http/Middleware/OAuthProxyMiddleware.php | 83 ++++++++++++++++-- .../ProtectedResourceMetadataMiddleware.php | 2 +- .../Transport/Http/OAuth/JwksProvider.php | 20 ++--- .../Middleware/OAuthProxyMiddlewareTest.php | 85 ++++++++++++++++++- .../Transport/Http/OAuth/JwksProviderTest.php | 8 ++ .../Http/OAuth/JwtTokenValidatorTest.php | 32 ++++--- 11 files changed, 221 insertions(+), 46 deletions(-) diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php index 9677c821..b9e99425 100644 --- a/examples/server/oauth-keycloak/server.php +++ b/examples/server/oauth-keycloak/server.php @@ -22,6 +22,7 @@ use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware; use Mcp\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; +use Mcp\Server\Transport\Http\OAuth\OidcDiscovery; use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; use Mcp\Server\Transport\StreamableHttpTransport; @@ -44,6 +45,7 @@ // Create PSR-17 factory $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); +$discovery = new OidcDiscovery(); // Create JWT validator // - issuer: accepts both external and internal issuer forms @@ -51,7 +53,7 @@ $validator = new JwtTokenValidator( issuer: [$externalIssuer, $internalIssuer], audience: $mcpAudience, - jwksProvider: new JwksProvider(), + jwksProvider: new JwksProvider(discovery: $discovery), jwksUri: $jwksUri, ); diff --git a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php index df684458..d4cb00a4 100644 --- a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php +++ b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php @@ -22,6 +22,12 @@ * - Standard JWT validation via JWKS (delegated/client credentials tokens) * - Claim-based Microsoft Graph token validation for tokens with a "nonce" header * + * Security notice: + * Tokens treated as Graph tokens (header contains "nonce") are validated by + * claims only in this example and are NOT signature-verified. + * This mode is intended for local/demo interoperability only and should not be + * used as-is in production deployments. + * * @author Volodymyr Panivko */ class MicrosoftJwtTokenValidator implements AuthorizationTokenValidatorInterface @@ -87,6 +93,7 @@ private function isGraphToken(array $parts): bool */ private function validateGraphToken(array $parts): AuthorizationResult { + // Intentionally claim-based only for example Graph token compatibility. if (\count($parts) < 2) { return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); } diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md index 1d7a6540..74239498 100644 --- a/examples/server/oauth-microsoft/README.md +++ b/examples/server/oauth-microsoft/README.md @@ -213,6 +213,7 @@ field can be missing or malformed and will not fail discovery. 2. **Use managed identities** in Azure deployments instead of client secrets 3. **Implement proper token refresh** in production clients 4. **Validate scopes** for sensitive operations +5. **Important:** `MicrosoftJwtTokenValidator` in this example accepts `nonce` Graph-style tokens via claim checks only (`iss`/`exp`/`nbf`) without signature verification. Treat this as demo-only behavior and replace it with full signature validation for production. ## Cleanup diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index 20a17cbb..eb1e7585 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -46,6 +46,9 @@ // Create PSR-17 factory $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); +$discovery = new OidcDiscovery( + metadataPolicy: new MicrosoftOidcMetadataPolicy(), +); // Create base JWT validator for Microsoft Entra ID // Microsoft uses the client ID as the audience for access tokens @@ -53,7 +56,7 @@ $jwtTokenValidator = new JwtTokenValidator( issuer: $issuers, audience: $clientId, - jwksProvider: new JwksProvider(), + jwksProvider: new JwksProvider(discovery: $discovery), // Microsoft's JWKS endpoint - use common endpoint for all Microsoft signing keys jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys', scopeClaim: 'scp', @@ -87,9 +90,7 @@ $oauthProxyMiddleware = new OAuthProxyMiddleware( upstreamIssuer: $issuerV2, localBaseUrl: $localBaseUrl, - discovery: new OidcDiscovery( - metadataPolicy: new MicrosoftOidcMetadataPolicy(), - ), + discovery: $discovery, clientSecret: $clientSecret, ); diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php index 3e8d4a3a..b8ae5112 100644 --- a/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php +++ b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php @@ -16,6 +16,7 @@ use Mcp\Server\Transport\Http\OAuth\AuthorizationResult; use Mcp\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; +use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -46,7 +47,7 @@ public function testNonGraphTokenUsesStandardJwtValidation(): void $jwtTokenValidator = new JwtTokenValidator( issuer: 'https://login.microsoftonline.com/tenant-id/v2.0', audience: 'mcp-api', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), jwksUri: $jwksUri, scopeClaim: 'scp', ); @@ -90,7 +91,7 @@ public function testGraphTokenWithNonceHeaderIsAllowed(): void $jwtTokenValidator = new JwtTokenValidator( issuer: ['https://auth.example.com'], audience: ['mcp-api'], - jwksProvider: new JwksProvider( + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), requestFactory: $factory ), @@ -124,7 +125,7 @@ public function testGraphTokenInvalidPayloadIsUnauthorized(): void $jwtTokenValidator = new JwtTokenValidator( issuer: ['https://auth.example.com'], audience: ['mcp-api'], - jwksProvider: new JwksProvider( + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), requestFactory: $factory ), @@ -160,7 +161,7 @@ public function testGraphTokenInvalidIssuerIsUnauthorized(): void $jwtTokenValidator = new JwtTokenValidator( issuer: ['https://auth.example.com'], audience: ['mcp-api'], - jwksProvider: new JwksProvider( + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), requestFactory: $factory ), @@ -187,7 +188,7 @@ public function testRequireScopesDelegatesToJwtTokenValidator(): void $jwtTokenValidator = new JwtTokenValidator( issuer: ['https://auth.example.com'], audience: ['mcp-api'], - jwksProvider: new JwksProvider( + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $this->createHttpClientMock([], 0), requestFactory: $factory, ), @@ -269,6 +270,11 @@ private function b64urlEncode(string $data): string return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } + /** * @param list $responses */ diff --git a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php index ba498c2e..b3e7fb48 100644 --- a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php @@ -38,6 +38,9 @@ */ final class OAuthProxyMiddleware implements MiddlewareInterface { + private const CLIENT_SECRET_BASIC = 'client_secret_basic'; + private const CLIENT_SECRET_POST = 'client_secret_post'; + private ?ClientInterface $httpClient; private ?RequestFactoryInterface $requestFactory; private ResponseFactoryInterface $responseFactory; @@ -115,8 +118,25 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface $body = $request->getBody()->__toString(); parse_str($body, $params); - if (null !== $this->clientSecret && !isset($params['client_secret'])) { - $params['client_secret'] = $this->clientSecret; + $upstreamAuthorization = trim($request->getHeaderLine('Authorization')); + if ('' === $upstreamAuthorization) { + $upstreamAuthorization = null; + } + + if (null !== $this->clientSecret && !isset($params['client_secret']) && null === $upstreamAuthorization) { + $authMethod = $this->resolveTokenEndpointAuthMethod(); + + if (self::CLIENT_SECRET_BASIC === $authMethod) { + $clientId = $params['client_id'] ?? null; + + if (\is_string($clientId) && '' !== trim($clientId)) { + $upstreamAuthorization = 'Basic '.base64_encode(trim($clientId).':'.$this->clientSecret); + } else { + $params['client_secret'] = $this->clientSecret; + } + } else { + $params['client_secret'] = $this->clientSecret; + } } $body = http_build_query($params); @@ -126,8 +146,8 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface ->withHeader('Content-Type', 'application/x-www-form-urlencoded') ->withBody($this->streamFactory->createStream($body)); - if ($request->hasHeader('Authorization')) { - $upstreamRequest = $upstreamRequest->withHeader('Authorization', $request->getHeaderLine('Authorization')); + if (null !== $upstreamAuthorization) { + $upstreamRequest = $upstreamRequest->withHeader('Authorization', $upstreamAuthorization); } try { @@ -152,10 +172,11 @@ private function createAuthServerMetadataResponse(): ResponseInterface return $this->createErrorResponse(500, 'Failed to discover upstream server metadata'); } + $localBaseUrl = rtrim($this->localBaseUrl, '/'); $localMetadata = [ - 'issuer' => $this->upstreamIssuer, - 'authorization_endpoint' => rtrim($this->localBaseUrl, '/').'/authorize', - 'token_endpoint' => rtrim($this->localBaseUrl, '/').'/token', + 'issuer' => $localBaseUrl, + 'authorization_endpoint' => $localBaseUrl.'/authorize', + 'token_endpoint' => $localBaseUrl.'/token', 'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'], 'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'], 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], @@ -190,6 +211,54 @@ private function createErrorResponse(int $status, string $message): ResponseInte ->withBody($this->streamFactory->createStream($body)); } + private function resolveTokenEndpointAuthMethod(): string + { + $supportedMethods = $this->getTokenEndpointAuthMethods(); + + if (\in_array(self::CLIENT_SECRET_BASIC, $supportedMethods, true)) { + return self::CLIENT_SECRET_BASIC; + } + + if (\in_array(self::CLIENT_SECRET_POST, $supportedMethods, true)) { + return self::CLIENT_SECRET_POST; + } + + return self::CLIENT_SECRET_POST; + } + + /** + * @return list + */ + private function getTokenEndpointAuthMethods(): array + { + try { + $metadata = $this->discovery->discover($this->upstreamIssuer); + } catch (\Throwable) { + return []; + } + + $methods = $metadata['token_endpoint_auth_methods_supported'] ?? null; + if (!\is_array($methods)) { + return []; + } + + $normalized = []; + foreach ($methods as $method) { + if (!\is_string($method)) { + continue; + } + + $method = trim($method); + if ('' === $method) { + continue; + } + + $normalized[] = $method; + } + + return array_values(array_unique($normalized)); + } + private function getHttpClient(): ClientInterface { return $this->httpClient ??= Psr18ClientDiscovery::find(); diff --git a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php index a3fc3f93..315f585f 100644 --- a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php @@ -50,7 +50,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $this->responseFactory ->createResponse(200) ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR))); + ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); } private function isMetadataRequest(ServerRequestInterface $request): bool diff --git a/src/Server/Transport/Http/OAuth/JwksProvider.php b/src/Server/Transport/Http/OAuth/JwksProvider.php index ba113968..50145d92 100644 --- a/src/Server/Transport/Http/OAuth/JwksProvider.php +++ b/src/Server/Transport/Http/OAuth/JwksProvider.php @@ -29,18 +29,23 @@ class JwksProvider implements JwksProviderInterface private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; - private ?OidcDiscoveryInterface $discovery; + /** + * @param OidcDiscoveryInterface $discovery OIDC discovery provider (required for JWKS URI resolution when $jwksUri is not explicit) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache Optional PSR-16 cache + * @param int $cacheTtl JWKS cache TTL in seconds + */ public function __construct( + private readonly OidcDiscoveryInterface $discovery, ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, - ?OidcDiscoveryInterface $discovery = null, private readonly ?CacheInterface $cache = null, private readonly int $cacheTtl = 3600, ) { $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); - $this->discovery = $discovery; } /** @@ -74,15 +79,6 @@ public function getJwks(string $issuer, ?string $jwksUri = null): array private function resolveJwksUri(string $issuer): string { - if (null === $this->discovery) { - $this->discovery = new OidcDiscovery( - $this->httpClient, - $this->requestFactory, - $this->cache, - $this->cacheTtl, - ); - } - return $this->discovery->getJwksUri($issuer); } diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php index 50017ccf..4ab36616 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php @@ -73,7 +73,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->assertSame(200, $response->getStatusCode()); $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); - $this->assertSame('https://login.example.com/tenant', $payload['issuer']); + $this->assertSame('http://localhost:8000', $payload['issuer']); $this->assertSame('http://localhost:8000/authorize', $payload['authorization_endpoint']); $this->assertSame('http://localhost:8000/token', $payload['token_endpoint']); $this->assertSame(['openid', 'profile'], $payload['scopes_supported']); @@ -132,6 +132,15 @@ public function testTokenEndpointProxiesRequestAndInjectsClientSecret(): void ->method('getTokenEndpoint') ->with('https://login.example.com/tenant') ->willReturn('https://login.example.com/oauth2/v2.0/token'); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'token_endpoint_auth_methods_supported' => ['client_secret_post'], + ]); $httpClient = $this->createMock(ClientInterface::class); $httpClient->expects($this->once()) @@ -139,7 +148,7 @@ public function testTokenEndpointProxiesRequestAndInjectsClientSecret(): void ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { $this->assertSame('POST', $request->getMethod()); $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); - $this->assertSame('Basic Zm9vOmJhcg==', $request->getHeaderLine('Authorization')); + $this->assertSame('', $request->getHeaderLine('Authorization')); $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); parse_str($request->getBody()->__toString(), $params); @@ -164,7 +173,6 @@ public function testTokenEndpointProxiesRequestAndInjectsClientSecret(): void ); $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') - ->withHeader('Authorization', 'Basic Zm9vOmJhcg==') ->withHeader('Content-Type', 'application/x-www-form-urlencoded') ->withBody($factory->createStream('grant_type=authorization_code&code=abc123')); @@ -186,6 +194,77 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); } + #[TestDox('token endpoint uses client_secret_basic when supported by upstream metadata')] + public function testTokenEndpointUsesClientSecretBasicWhenSupported(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getTokenEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/token'); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'token_endpoint_auth_methods_supported' => ['client_secret_basic'], + ]); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); + $this->assertSame('Basic ZGVtby1jbGllbnQ6c2VjcmV0LXZhbHVl', $request->getHeaderLine('Authorization')); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + + parse_str($request->getBody()->__toString(), $params); + $this->assertSame('authorization_code', $params['grant_type'] ?? null); + $this->assertSame('abc123', $params['code'] ?? null); + $this->assertArrayNotHasKey('client_secret', $params); + + return $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{"access_token":"token-1"}')); + }); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + clientSecret: 'secret-value', + discovery: $discovery, + httpClient: $httpClient, + requestFactory: $factory, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($factory->createStream('grant_type=authorization_code&client_id=demo-client&code=abc123')); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); + } + #[TestDox('non oauth proxy requests are delegated to next middleware')] public function testNonOAuthRequestPassesThrough(): void { diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php index a668bcb4..78c1572e 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php @@ -48,6 +48,7 @@ public function testGetJwksFromExplicitUri(): void ); $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory, ); @@ -85,6 +86,7 @@ public function testInvalidCachedJwksAreIgnored(): void ->method('set'); $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory, cache: $cache, @@ -148,6 +150,7 @@ public function testEmptyKeysThrow(): void ); $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory, ); @@ -157,4 +160,9 @@ public function testEmptyKeysThrow(): void $provider->getJwks('https://auth.example.com', $jwksUri); } + + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } } diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php index 09a1c91e..493a1520 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\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; +use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -45,7 +46,7 @@ public function testValidJwtAllowsAndExposesAttributes(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: $jwksUri, - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -94,7 +95,7 @@ public function testIssuerMismatchIsUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: $jwksUri, - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -136,7 +137,7 @@ public function testAudienceMismatchIsUnauthorized(): void issuer: 'https://auth.example.com', audience: ['mcp-api'], jwksUri: $jwksUri, - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -177,7 +178,7 @@ public function testExpiredTokenIsUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -217,7 +218,7 @@ public function testBeforeValidTokenIsUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -262,7 +263,7 @@ public function testSignatureInvalidIsUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -295,7 +296,7 @@ public function testJwksHttpErrorResultsInUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), requestFactory: $factory), ); // Unsigned token forces the validator to load JWKS and fail on HTTP 500. @@ -323,7 +324,7 @@ public function testInvalidJwksJsonResultsInUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); @@ -350,7 +351,7 @@ public function testJwksMissingKeysResultsInUnauthorized(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); @@ -378,7 +379,7 @@ public function testRequireScopesForbiddenWhenMissing(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -421,7 +422,7 @@ public function testRequireScopesPassesWhenPresent(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $token = JWT::encode( @@ -462,7 +463,7 @@ public function testExtractScopesEdgeCases(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), ); $tokenMissing = JWT::encode( @@ -489,7 +490,7 @@ public function testExtractScopesEdgeCases(): void issuer: 'https://auth.example.com', audience: 'mcp-api', jwksUri: 'https://auth.example.com/jwks', - jwksProvider: new JwksProvider(httpClient: $httpClient2, requestFactory: $factory), + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient2, requestFactory: $factory), ); $tokenInvalid = JWT::encode( @@ -563,6 +564,11 @@ private function b64urlEncode(string $data): string return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } + /** * @param list $responses */ From cbdb54642a6d26d30aa16f96daa457ceaad0da0a Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Sun, 22 Feb 2026 12:46:07 +0100 Subject: [PATCH 13/18] Remove local development docker artifacts from VCS --- .docker/nginx.local.conf | 13 ------------ .docker/php-local.Dockerfile | 11 ---------- .gitignore | 2 ++ docker-compose.local.yml | 39 ------------------------------------ phpunit.xml.dist | 2 +- 5 files changed, 3 insertions(+), 64 deletions(-) delete mode 100644 .docker/nginx.local.conf delete mode 100644 .docker/php-local.Dockerfile delete mode 100644 docker-compose.local.yml diff --git a/.docker/nginx.local.conf b/.docker/nginx.local.conf deleted file mode 100644 index 51f212c7..00000000 --- a/.docker/nginx.local.conf +++ /dev/null @@ -1,13 +0,0 @@ -server { - listen 80; - server_name _; - - # Route all requests to the conformance test server script. - location / { - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME /app/tests/Conformance/server.php; - fastcgi_param SCRIPT_NAME /tests/Conformance/server.php; - fastcgi_param QUERY_STRING $query_string; - fastcgi_pass php-fpm:9000; - } -} diff --git a/.docker/php-local.Dockerfile b/.docker/php-local.Dockerfile deleted file mode 100644 index 1657b3cd..00000000 --- a/.docker/php-local.Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM composer:2.8 - -RUN set -eux; \ - apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers; \ - pecl install xdebug; \ - docker-php-ext-enable xdebug; \ - apk del .build-deps; \ - { \ - echo 'xdebug.mode=coverage'; \ - echo 'xdebug.start_with_request=no'; \ - } > /usr/local/etc/php/conf.d/zz-xdebug.ini diff --git a/.gitignore b/.gitignore index 6dc5d9ec..c6bf9906 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ composer.lock coverage vendor +/.docker/ +/docker-compose.local.yml examples/**/dev.log examples/**/cache examples/**/sessions diff --git a/docker-compose.local.yml b/docker-compose.local.yml deleted file mode 100644 index a539379a..00000000 --- a/docker-compose.local.yml +++ /dev/null @@ -1,39 +0,0 @@ -services: - php: - build: - context: . - dockerfile: .docker/php-local.Dockerfile - working_dir: /app - user: "${UID:-1000}:${GID:-1000}" - environment: - COMPOSER_ALLOW_SUPERUSER: "1" - COMPOSER_HOME: /tmp/composer - COMPOSER_CACHE_DIR: /tmp/composer/cache - XDEBUG_MODE: coverage - volumes: - - ./:/app - - composer-cache:/tmp/composer/cache - tty: true - stdin_open: true - command: sh -lc "while sleep 3600; do :; done" - - php-fpm: - image: php:8.4-fpm-alpine - profiles: ["http"] - working_dir: /app - volumes: - - ./:/app - - nginx: - image: nginx:1.26-alpine - profiles: ["http"] - ports: - - "8000:80" - volumes: - - ./.docker/nginx.local.conf:/etc/nginx/conf.d/default.conf:ro - - ./:/app:ro - depends_on: - - php-fpm - -volumes: - composer-cache: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 50c13a22..54c2a8e1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ Date: Sun, 22 Feb 2026 12:46:20 +0100 Subject: [PATCH 14/18] Revert .gitignore changes for local docker files --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index c6bf9906..6dc5d9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,6 @@ composer.lock coverage vendor -/.docker/ -/docker-compose.local.yml examples/**/dev.log examples/**/cache examples/**/sessions From 1096f8e2906ab8967cdfa9bd8fd319338f739b4d Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Thu, 26 Feb 2026 13:47:27 +0100 Subject: [PATCH 15/18] Fix body retrieval in OAuthRequestMetaMiddleware for proper string conversion Signed-off-by: Volodymyr Panivko --- .../Transport/Http/Middleware/OAuthRequestMetaMiddleware.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php index 9b940dab..9301352e 100644 --- a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php +++ b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php @@ -44,7 +44,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $handler->handle($request); } - $body = (string) $request->getBody(); + $body = $request->getBody()->__toString(); if ('' === trim($body)) { return $handler->handle($request); } From 2943a06dd2f6b0d61b2a56bc08ff9b3d5d5f0ecd Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 2 Mar 2026 12:56:51 +0100 Subject: [PATCH 16/18] Address OAuth PR review feedback and align example configs --- examples/server/oauth-keycloak/README.md | 4 +- .../server/oauth-keycloak/docker-compose.yml | 7 +-- examples/server/oauth-keycloak/server.php | 52 ++++--------------- .../MicrosoftJwtTokenValidator.php | 8 +-- examples/server/oauth-microsoft/server.php | 49 +++-------------- .../Http/Middleware/OAuthProxyMiddleware.php | 12 +++-- .../Transport/Http/OAuth/JwksProvider.php | 17 ++---- .../Http/OAuth/JwtTokenValidator.php | 29 ++++------- .../Transport/Http/OAuth/OidcDiscovery.php | 23 ++++---- .../Middleware/JwtTokenValidator.php | 2 +- .../Middleware/OAuthProxyMiddleware.php | 6 +-- .../Transport/Middleware/OidcDiscovery.php | 2 +- 12 files changed, 63 insertions(+), 148 deletions(-) diff --git a/examples/server/oauth-keycloak/README.md b/examples/server/oauth-keycloak/README.md index d1929318..f9f46de7 100644 --- a/examples/server/oauth-keycloak/README.md +++ b/examples/server/oauth-keycloak/README.md @@ -106,7 +106,7 @@ Access at http://localhost:8180/admin with: This example uses hard-coded values in `server.php` for consistency with other examples: - Keycloak external URL: `http://localhost:8180` -- Keycloak internal URL: `http://keycloak:8080` +- Keycloak internal URL: `http://keycloak:8180` - Realm: `mcp` - Audience: `mcp-server` @@ -125,7 +125,7 @@ This example uses hard-coded values in `server.php` for consistency with other e ### JWKS fetch fails -The MCP server needs to reach Keycloak at `http://keycloak:8080` (Docker network). +The MCP server needs to reach Keycloak at `http://keycloak:8180` (Docker network). For local development outside Docker, use `http://localhost:8180`. ## Cleanup diff --git a/examples/server/oauth-keycloak/docker-compose.yml b/examples/server/oauth-keycloak/docker-compose.yml index 6d188040..21562445 100644 --- a/examples/server/oauth-keycloak/docker-compose.yml +++ b/examples/server/oauth-keycloak/docker-compose.yml @@ -11,10 +11,11 @@ services: command: - start-dev - --import-realm + - --http-port=8180 ports: - - "8180:8080" + - "8180:8180" healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] interval: 10s timeout: 5s retries: 15 @@ -32,7 +33,7 @@ services: working_dir: /app environment: KEYCLOAK_EXTERNAL_URL: http://localhost:8180 - KEYCLOAK_INTERNAL_URL: http://keycloak:8080 + KEYCLOAK_INTERNAL_URL: http://keycloak:8180 KEYCLOAK_REALM: mcp MCP_AUDIENCE: mcp-server depends_on: diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php index b9e99425..e9f5acbc 100644 --- a/examples/server/oauth-keycloak/server.php +++ b/examples/server/oauth-keycloak/server.php @@ -26,39 +26,16 @@ use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; use Mcp\Server\Transport\StreamableHttpTransport; -// Configuration -// External URL is what clients use and what appears in tokens -$keycloakExternalUrl = 'http://localhost:8180'; -// Internal URL is how this server reaches Keycloak (Docker network) -$keycloakInternalUrl = 'http://keycloak:8080'; -$keycloakRealm = 'mcp'; -$mcpAudience = 'mcp-server'; +$externalIssuer = 'http://localhost:8180/realms/mcp'; +$internalIssuer = 'http://keycloak:8180/realms/mcp'; -// Accept both issuers: -// - external issuer for clients outside Docker -// - internal issuer for tokens requested from within Docker network -$externalIssuer = rtrim($keycloakExternalUrl, '/').'/realms/'.$keycloakRealm; -$internalIssuer = rtrim($keycloakInternalUrl, '/').'/realms/'.$keycloakRealm; -// JWKS URI uses internal URL to reach Keycloak within Docker network -$jwksUri = rtrim($keycloakInternalUrl, '/').'/realms/'.$keycloakRealm.'/protocol/openid-connect/certs'; - -// Create PSR-17 factory -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); -$discovery = new OidcDiscovery(); - -// Create JWT validator -// - issuer: accepts both external and internal issuer forms -// - jwksUri: where to fetch keys (internal URL) $validator = new JwtTokenValidator( issuer: [$externalIssuer, $internalIssuer], - audience: $mcpAudience, - jwksProvider: new JwksProvider(discovery: $discovery), - jwksUri: $jwksUri, + audience: 'mcp-server', + jwksProvider: new JwksProvider(new OidcDiscovery()), + jwksUri: $internalIssuer.'/protocol/openid-connect/certs', ); -// Create a shared Protected Resource Metadata object (RFC 9728). -// It is used both for the metadata endpoint and for WWW-Authenticate hints. $protectedResourceMetadata = new ProtectedResourceMetadata( authorizationServers: [$externalIssuer], scopesSupported: ['openid'], @@ -66,19 +43,13 @@ resourceName: 'OAuth Keycloak Example MCP Server', ); -// Create middleware serving Protected Resource Metadata (RFC 9728). -$metadataMiddleware = new ProtectedResourceMetadataMiddleware( - metadata: $protectedResourceMetadata, -); +$metadataMiddleware = new ProtectedResourceMetadataMiddleware($protectedResourceMetadata); -// Create authorization middleware. $authMiddleware = new AuthorizationMiddleware( - validator: $validator, - resourceMetadata: $protectedResourceMetadata, + $validator, + $protectedResourceMetadata, ); -$oauthRequestMetaMiddleware = new OAuthRequestMetaMiddleware(); -// Build MCP server $server = Server::builder() ->setServerInfo('OAuth Keycloak Example', '1.0.0') ->setLogger(logger()) @@ -86,15 +57,12 @@ ->setDiscovery(__DIR__) ->build(); -// Create transport with authorization middleware $transport = new StreamableHttpTransport( - $request, + (new Psr17Factory())->createServerRequestFromGlobals(), logger: logger(), - middleware: [$metadataMiddleware, $authMiddleware, $oauthRequestMetaMiddleware], + middleware: [$metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()], ); -// Run server $response = $server->run($transport); -// Emit response (new SapiEmitter())->emit($response); diff --git a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php index d4cb00a4..9b323de8 100644 --- a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php +++ b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php @@ -141,13 +141,9 @@ private function validateGraphToken(array $parts): AuthorizationResult private function isTrustedGraphIssuer(string $issuer): bool { - foreach ($this->trustedGraphIssuers as $marker) { - if (str_contains($issuer, $marker)) { - return true; - } - } + $host = parse_url($issuer, \PHP_URL_HOST); - return false; + return in_array($host, $this->trustedGraphIssuers, true); } /** diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index eb1e7585..de16ab5c 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -12,8 +12,6 @@ */ require_once dirname(__DIR__).'/bootstrap.php'; -require_once __DIR__.'/MicrosoftJwtTokenValidator.php'; -require_once __DIR__.'/MicrosoftOidcMetadataPolicy.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; @@ -31,44 +29,27 @@ use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; use Mcp\Server\Transport\StreamableHttpTransport; -// Configuration from environment $tenantId = getenv('AZURE_TENANT_ID') ?: throw new RuntimeException('AZURE_TENANT_ID environment variable is required'); $clientId = getenv('AZURE_CLIENT_ID') ?: throw new RuntimeException('AZURE_CLIENT_ID environment variable is required'); -// Microsoft Entra ID issuer URLs -// v2.0 tokens (delegated/user flows): https://login.microsoftonline.com/{tenant}/v2.0 -// v1.0 tokens (client credentials flow): https://sts.windows.net/{tenant}/ $issuerV2 = "https://login.microsoftonline.com/{$tenantId}/v2.0"; $issuerV1 = "https://sts.windows.net/{$tenantId}/"; -$issuers = [$issuerV2, $issuerV1]; $localBaseUrl = 'http://localhost:8000'; -// Create PSR-17 factory -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $discovery = new OidcDiscovery( metadataPolicy: new MicrosoftOidcMetadataPolicy(), ); -// Create base JWT validator for Microsoft Entra ID -// Microsoft uses the client ID as the audience for access tokens -// Accept both v1.0 and v2.0 issuers to support various token flows $jwtTokenValidator = new JwtTokenValidator( - issuer: $issuers, + issuer: [$issuerV2, $issuerV1], audience: $clientId, - jwksProvider: new JwksProvider(discovery: $discovery), - // Microsoft's JWKS endpoint - use common endpoint for all Microsoft signing keys + jwksProvider: new JwksProvider($discovery), jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys', scopeClaim: 'scp', ); -// Decorate base validator with Graph-token handling. -$validator = new MicrosoftJwtTokenValidator( - jwtTokenValidator: $jwtTokenValidator, -); +$validator = new MicrosoftJwtTokenValidator($jwtTokenValidator); -// Create a shared Protected Resource Metadata object (RFC 9728). -// It is used both for the metadata endpoint and for WWW-Authenticate hints. $protectedResourceMetadata = new ProtectedResourceMetadata( authorizationServers: [$localBaseUrl], scopesSupported: ['openid', 'profile', 'email'], @@ -76,17 +57,10 @@ resourceDocumentation: $localBaseUrl, ); -// Create middleware serving Protected Resource Metadata (RFC 9728). -$metadataMiddleware = new ProtectedResourceMetadataMiddleware( - metadata: $protectedResourceMetadata, -); +$metadataMiddleware = new ProtectedResourceMetadataMiddleware($protectedResourceMetadata); -// Get client secret for confidential client flow $clientSecret = getenv('AZURE_CLIENT_SECRET') ?: null; -// Create OAuth proxy middleware to handle /authorize and /token endpoints -// This proxies OAuth requests to Microsoft Entra ID -// The clientSecret is injected server-side since mcp-remote doesn't have access to it $oauthProxyMiddleware = new OAuthProxyMiddleware( upstreamIssuer: $issuerV2, localBaseUrl: $localBaseUrl, @@ -94,14 +68,11 @@ clientSecret: $clientSecret, ); -// Create authorization middleware $authMiddleware = new AuthorizationMiddleware( - validator: $validator, - resourceMetadata: $protectedResourceMetadata, + $validator, + $protectedResourceMetadata, ); -$oauthRequestMetaMiddleware = new OAuthRequestMetaMiddleware(); -// Build MCP server $server = Server::builder() ->setServerInfo('OAuth Microsoft Example', '1.0.0') ->setLogger(logger()) @@ -109,16 +80,12 @@ ->setDiscovery(__DIR__) ->build(); -// Create transport with OAuth proxy and authorization middlewares -// Order matters: first matching middleware handles the request. $transport = new StreamableHttpTransport( - $request, + (new Psr17Factory())->createServerRequestFromGlobals(), logger: logger(), - middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, $oauthRequestMetaMiddleware], + middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()], ); -// Run server $response = $server->run($transport); -// Emit response (new SapiEmitter())->emit($response); diff --git a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php index b3e7fb48..68307a5e 100644 --- a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php @@ -13,7 +13,9 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; +use Mcp\Exception\RuntimeException; use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ResponseFactoryInterface; @@ -91,7 +93,7 @@ private function handleAuthorize(ServerRequestInterface $request): ResponseInter { try { $authorizationEndpoint = $this->discovery->getAuthorizationEndpoint($this->upstreamIssuer); - } catch (\Throwable) { + } catch (RuntimeException) { return $this->createErrorResponse(500, 'Upstream authorization endpoint not found'); } @@ -111,7 +113,7 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface { try { $tokenEndpoint = $this->discovery->getTokenEndpoint($this->upstreamIssuer); - } catch (\Throwable) { + } catch (RuntimeException) { return $this->createErrorResponse(500, 'Upstream token endpoint not found'); } @@ -159,7 +161,7 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface ->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type')) ->withHeader('Cache-Control', 'no-store') ->withBody($this->streamFactory->createStream($responseBody)); - } catch (\Throwable $e) { + } catch (ClientExceptionInterface $e) { return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: '.$e->getMessage()); } } @@ -168,7 +170,7 @@ private function createAuthServerMetadataResponse(): ResponseInterface { try { $upstreamMetadata = $this->discovery->discover($this->upstreamIssuer); - } catch (\Throwable) { + } catch (RuntimeException) { return $this->createErrorResponse(500, 'Failed to discover upstream server metadata'); } @@ -233,7 +235,7 @@ private function getTokenEndpointAuthMethods(): array { try { $metadata = $this->discovery->discover($this->upstreamIssuer); - } catch (\Throwable) { + } catch (RuntimeException) { return []; } diff --git a/src/Server/Transport/Http/OAuth/JwksProvider.php b/src/Server/Transport/Http/OAuth/JwksProvider.php index 50145d92..47a59a39 100644 --- a/src/Server/Transport/Http/OAuth/JwksProvider.php +++ b/src/Server/Transport/Http/OAuth/JwksProvider.php @@ -14,6 +14,7 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use Mcp\Exception\RuntimeException; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\SimpleCache\CacheInterface; @@ -53,7 +54,7 @@ public function __construct( */ public function getJwks(string $issuer, ?string $jwksUri = null): array { - $jwksUri ??= $this->resolveJwksUri($issuer); + $jwksUri ??= $this->discovery->getJwksUri($issuer); $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri); if (null !== $this->cache) { @@ -77,14 +78,6 @@ public function getJwks(string $issuer, ?string $jwksUri = null): array return $jwks; } - private function resolveJwksUri(string $issuer): string - { - return $this->discovery->getJwksUri($issuer); - } - - /** - * @return array - */ private function fetchJwks(string $jwksUri): array { $request = $this->requestFactory->createRequest('GET', $jwksUri) @@ -92,7 +85,7 @@ private function fetchJwks(string $jwksUri): array try { $response = $this->httpClient->sendRequest($request); - } catch (\Throwable $e) { + } catch (ClientExceptionInterface $e) { throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: %s', $jwksUri, $e->getMessage()), 0, $e); } @@ -100,10 +93,8 @@ private function fetchJwks(string $jwksUri): array throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode())); } - $body = $response->getBody()->__toString(); - try { - $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + $data = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); } diff --git a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php index 6291314f..50afb367 100644 --- a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php +++ b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php @@ -16,6 +16,7 @@ use Firebase\JWT\JWK; use Firebase\JWT\JWT; use Firebase\JWT\SignatureInvalidException; +use Mcp\Exception\RuntimeException; /** * Validates JWT access tokens using JWKS from an OAuth 2.0 / OpenID Connect provider. @@ -47,39 +48,31 @@ public function __construct( private readonly array $algorithms = ['RS256', 'RS384', 'RS512'], private readonly string $scopeClaim = 'scope', ) { + if (!class_exists(JWT::class)) { + throw new RuntimeException('For using the JwtTokenValidator, the firebase/php-jwt package is required. Try running "composer require firebase/php-jwt".'); + } } public function validate(string $accessToken): AuthorizationResult { try { - $keys = $this->getJwks(); - $decoded = JWT::decode($accessToken, $keys); /** @var array $claims */ - $claims = (array) $decoded; + $claims = (array) JWT::decode($accessToken, $this->getJwks()); // Validate issuer if (!$this->validateIssuer($claims)) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token issuer mismatch.' - ); + return AuthorizationResult::unauthorized('invalid_token', 'Token issuer mismatch.'); } // Validate audience if (!$this->validateAudience($claims)) { - return AuthorizationResult::unauthorized( - 'invalid_token', - 'Token audience mismatch.' - ); + return AuthorizationResult::unauthorized('invalid_token', 'Token audience mismatch.'); } - // Extract scopes - $scopes = $this->extractScopes($claims); - // Build attributes to attach to request $attributes = [ 'oauth.claims' => $claims, - 'oauth.scopes' => $scopes, + 'oauth.scopes' => $this->extractScopes($claims), ]; // Add common claims as individual attributes @@ -134,11 +127,7 @@ public function requireScopes(AuthorizationResult $result, array $requiredScopes foreach ($requiredScopes as $required) { if (!\in_array($required, $tokenScopes, true)) { - return AuthorizationResult::forbidden( - 'insufficient_scope', - \sprintf('Required scope: %s', $required), - $requiredScopes - ); + return AuthorizationResult::forbidden('insufficient_scope', \sprintf('Required scope: %s', $required), $requiredScopes); } } diff --git a/src/Server/Transport/Http/OAuth/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php index dbff831c..2f246852 100644 --- a/src/Server/Transport/Http/OAuth/OidcDiscovery.php +++ b/src/Server/Transport/Http/OAuth/OidcDiscovery.php @@ -14,6 +14,7 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use Mcp\Exception\RuntimeException; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\SimpleCache\CacheInterface; @@ -32,12 +33,12 @@ */ class OidcDiscovery implements OidcDiscoveryInterface { + private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; + private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; private OidcDiscoveryMetadataPolicyInterface $metadataPolicy; - private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; - /** * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) @@ -125,7 +126,7 @@ public function discover(string $issuer): array if (null !== $this->cache) { $cached = $this->cache->get($cacheKey); - if ($this->metadataPolicy->isValid($cached)) { + if (\is_array($cached)) { /* @var array $cached */ return $cached; } @@ -185,9 +186,11 @@ private function fetchMetadata(string $issuer): array throw new RuntimeException(\sprintf('OIDC discovery response from %s has invalid format.', $url)); } - // Validate issuer claim matches - if (isset($metadata['issuer']) && $metadata['issuer'] !== $issuer) { - continue; + if (!isset($metadata['issuer']) || !\is_string($metadata['issuer'])) { + throw new RuntimeException(\sprintf('OIDC discovery response from %s is missing required "issuer" field.', $url)); + } + if ($metadata['issuer'] !== $issuer) { + throw new RuntimeException(\sprintf('OIDC discovery issuer mismatch for %s: expected %s, got %s.', $url, $issuer, $metadata['issuer'])); } return $metadata; @@ -210,18 +213,16 @@ private function fetchJson(string $url): array try { $response = $this->httpClient->sendRequest($request); - } catch (\Throwable $e) { + } catch (ClientExceptionInterface $e) { throw new RuntimeException(\sprintf('HTTP request to %s failed: %s', $url, $e->getMessage()), 0, $e); } - if ($response->getStatusCode() >= 400) { + if (200 !== $response->getStatusCode()) { throw new RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); } - $body = $response->getBody()->__toString(); - try { - $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + $data = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { throw new RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); } diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php index b557dfa8..076af516 100644 --- a/src/Server/Transport/Middleware/JwtTokenValidator.php +++ b/src/Server/Transport/Middleware/JwtTokenValidator.php @@ -332,7 +332,7 @@ private function fetchJwks(string $jwksUri): array )); } - $body = (string) $response->getBody(); + $body = $response->getBody()->__toString(); try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); diff --git a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php index a0a5e5f1..0f5c94c6 100644 --- a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php @@ -119,7 +119,7 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface } // Get the request body and parse it - $body = (string)$request->getBody(); + $body = $request->getBody()->__toString(); parse_str($body, $params); // Inject client_secret if configured and not already present @@ -143,7 +143,7 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface try { $upstreamResponse = $this->httpClient->sendRequest($upstreamRequest); - $responseBody = (string)$upstreamResponse->getBody(); + $responseBody = $upstreamResponse->getBody()->__toString(); // Return upstream response as-is return $this->responseFactory @@ -208,7 +208,7 @@ private function getUpstreamMetadata(): array $response = $this->httpClient->sendRequest($request); if (200 === $response->getStatusCode()) { - $this->upstreamMetadata = json_decode((string)$response->getBody(), true) ?? []; + $this->upstreamMetadata = json_decode($response->getBody()->__toString(), true) ?? []; return $this->upstreamMetadata; } diff --git a/src/Server/Transport/Middleware/OidcDiscovery.php b/src/Server/Transport/Middleware/OidcDiscovery.php index 5124c83c..4721c24b 100644 --- a/src/Server/Transport/Middleware/OidcDiscovery.php +++ b/src/Server/Transport/Middleware/OidcDiscovery.php @@ -274,7 +274,7 @@ private function fetchJson(string $url): array )); } - $body = (string)$response->getBody(); + $body = $response->getBody()->__toString(); try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); From 4f555f01626b0e175c449fe5ac256b10f4b1e777 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 2 Mar 2026 13:22:25 +0100 Subject: [PATCH 17/18] Fix QA style issues and add authorization docs --- docs/authorization.md | 421 ++++++++++++++++++ .../MicrosoftJwtTokenValidator.php | 2 +- .../Middleware/AuthorizationMiddleware.php | 32 +- .../Middleware/AuthorizationResult.php | 25 +- .../AuthorizationTokenValidatorInterface.php | 4 +- .../Middleware/JwtTokenValidator.php | 46 +- .../Middleware/OAuthProxyMiddleware.php | 18 +- .../Transport/Middleware/OidcDiscovery.php | 42 +- .../Middleware/ProtectedResourceMetadata.php | 8 +- .../Middleware/JwtTokenValidatorTest.php | 20 +- 10 files changed, 512 insertions(+), 106 deletions(-) create mode 100644 docs/authorization.md diff --git a/docs/authorization.md b/docs/authorization.md new file mode 100644 index 00000000..e9d41a42 --- /dev/null +++ b/docs/authorization.md @@ -0,0 +1,421 @@ +# Authorization + +The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, implementing the +[MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#quick-start) +- [Components](#components) +- [JWT Token Validation](#jwt-token-validation) +- [Protected Resource Metadata](#protected-resource-metadata) +- [Custom Token Validators](#custom-token-validators) +- [Scope-Based Access Control](#scope-based-access-control) +- [Examples](#examples) + +## Overview + +Authorization in MCP is implemented at the transport level using PSR-15 middleware. The SDK provides: + +- **AuthorizationMiddleware** - PSR-15 middleware that enforces bearer token authentication +- **JwtTokenValidator** - Validates JWT tokens using JWKS from OAuth 2.0 / OIDC providers +- **ProtectedResourceMetadata** - Implements RFC 9728 for authorization server discovery +- **OidcDiscovery** - Discovers authorization server metadata from well-known endpoints + +``` +┌─────────────┐ ┌────────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ AuthorizationMiddleware │────▶│ MCP Handlers │ +└─────────────┘ └────────────────────┘ └─────────────────┘ + │ │ + │ │ Validate JWT + ▼ ▼ +┌─────────────┐ ┌─────────────────┐ +│ Auth Server │◀────│ JwtTokenValidator│ +│ (Keycloak, │ │ + JWKS │ +│ Entra ID) │ └─────────────────┘ +└─────────────┘ +``` + +## Quick Start + +```php +use Mcp\Server; +use Mcp\Server\Transport\Middleware\AuthorizationMiddleware; +use Mcp\Server\Transport\Middleware\JwtTokenValidator; +use Mcp\Server\Transport\Middleware\ProtectedResourceMetadata; +use Mcp\Server\Transport\StreamableHttpTransport; + +// 1. Create JWT validator for your OAuth provider +$validator = new JwtTokenValidator( + issuer: 'https://auth.example.com/realms/mcp', + audience: 'mcp-server', +); + +// 2. Create Protected Resource Metadata (RFC 9728) +$metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com/realms/mcp'], + scopesSupported: ['mcp:read', 'mcp:write'], +); + +// 3. Create the authorization middleware +$authMiddleware = new AuthorizationMiddleware( + metadata: $metadata, + validator: $validator, + metadataPaths: ['/.well-known/oauth-protected-resource'], +); + +// 4. Create transport with middleware +$transport = new StreamableHttpTransport( + $request, + middlewares: [$authMiddleware], +); + +// 5. Run server +$server = Server::builder() + ->setServerInfo('Protected MCP Server', '1.0.0') + ->setDiscovery(__DIR__) + ->build(); + +$response = $server->run($transport); +``` + +## Components + +### AuthorizationMiddleware + +The main middleware that enforces authentication: + +```php +$middleware = new AuthorizationMiddleware( + metadata: $metadata, // ProtectedResourceMetadata instance + validator: $validator, // AuthorizationTokenValidatorInterface + responseFactory: null, // PSR-17 (auto-discovered) + streamFactory: null, // PSR-17 (auto-discovered) + metadataPaths: [ // Paths to serve metadata + '/.well-known/oauth-protected-resource', + ], + resourceMetadataUrl: null, // Explicit URL for WWW-Authenticate + scopeProvider: null, // Callback for dynamic scope requirements +); +``` + +**Behavior:** + +| Request | Response | +|---------|----------| +| GET to metadata path | Returns Protected Resource Metadata JSON | +| Missing Authorization header | 401 with `WWW-Authenticate: Bearer resource_metadata="..."` | +| Invalid/expired token | 401 with error details | +| Valid token | Passes to next handler with OAuth attributes on request | + +### JwtTokenValidator + +Validates JWT access tokens: + +```php +$validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', // Expected issuer claim + audience: 'mcp-server', // Expected audience (string or array) + jwksUri: null, // Auto-discovered from issuer + httpClient: null, // PSR-18 (auto-discovered) + requestFactory: null, // PSR-17 (auto-discovered) + cache: $psrCache, // PSR-16 cache (optional) + cacheTtl: 3600, // JWKS cache TTL in seconds + algorithms: ['RS256', 'RS384'], // Allowed algorithms + scopeClaim: 'scope', // Claim name for scopes +); +``` + +**Request Attributes:** + +After successful validation, these attributes are added to the request: + +| Attribute | Description | +|-----------|-------------| +| `oauth.claims` | All JWT claims as array | +| `oauth.scopes` | Extracted scopes as array | +| `oauth.subject` | The `sub` claim | +| `oauth.client_id` | The `client_id` claim (if present) | +| `oauth.authorized_party` | The `azp` claim (if present) | + +### ProtectedResourceMetadata + +Represents RFC 9728 Protected Resource Metadata: + +```php +$metadata = new ProtectedResourceMetadata( + authorizationServers: [ // Required: authorization server URLs + 'https://auth.example.com', + ], + scopesSupported: [ // Optional: supported scopes + 'mcp:read', + 'mcp:write', + ], + resource: 'https://mcp.example.com', // Optional: resource identifier + extra: [ // Optional: additional fields + 'custom_field' => 'value', + ], +); + +// Serve as JSON +$json = $metadata->toJson(); +``` + +### OidcDiscovery + +Discovers OAuth/OIDC server metadata: + +```php +$discovery = new OidcDiscovery( + httpClient: null, // PSR-18 (auto-discovered) + requestFactory: null, // PSR-17 (auto-discovered) + cache: $cache, // PSR-16 cache (optional) + cacheTtl: 3600, // Cache TTL +); + +// Discover metadata +$metadata = $discovery->discover('https://auth.example.com/realms/mcp'); + +// Get specific endpoints +$jwksUri = $discovery->getJwksUri($issuer); +$tokenEndpoint = $discovery->getTokenEndpoint($issuer); +$authEndpoint = $discovery->getAuthorizationEndpoint($issuer); + +// Check PKCE support +$supportsPkce = $discovery->supportsPkce($issuer); +``` + +## JWT Token Validation + +### Keycloak + +```php +$validator = new JwtTokenValidator( + issuer: 'https://keycloak.example.com/realms/mcp', + audience: 'mcp-server', +); +``` + +### Microsoft Entra ID (Azure AD) + +```php +$tenantId = 'your-tenant-id'; +$clientId = 'your-client-id'; + +$validator = new JwtTokenValidator( + issuer: "https://login.microsoftonline.com/{$tenantId}/v2.0", + audience: $clientId, +); +``` + +### Auth0 + +```php +$validator = new JwtTokenValidator( + issuer: 'https://your-tenant.auth0.com/', + audience: 'https://api.example.com', +); +``` + +### Okta + +```php +$validator = new JwtTokenValidator( + issuer: 'https://your-org.okta.com/oauth2/default', + audience: 'api://default', +); +``` + +## Protected Resource Metadata + +The middleware serves Protected Resource Metadata at configured paths, enabling clients to discover the authorization server: + +```json +{ + "authorization_servers": ["https://auth.example.com/realms/mcp"], + "scopes_supported": ["mcp:read", "mcp:write"], + "resource": "https://mcp.example.com/mcp" +} +``` + +Clients request this from `/.well-known/oauth-protected-resource` before authenticating. + +### WWW-Authenticate Header + +On 401 responses, the middleware includes: + +``` +WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", + scope="mcp:read mcp:write" +``` + +## Custom Token Validators + +Implement `AuthorizationTokenValidatorInterface` for custom validation: + +```php +use Mcp\Server\Transport\Middleware\AuthorizationTokenValidatorInterface; +use Mcp\Server\Transport\Middleware\AuthorizationResult; +use Psr\Http\Message\ServerRequestInterface; + +final class ApiKeyValidator implements AuthorizationTokenValidatorInterface +{ + public function __construct( + private array $validKeys, + ) {} + + public function validate(ServerRequestInterface $request, string $accessToken): AuthorizationResult + { + if (!isset($this->validKeys[$accessToken])) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Unknown API key' + ); + } + + $keyInfo = $this->validKeys[$accessToken]; + + return AuthorizationResult::allow([ + 'api_key.name' => $keyInfo['name'], + 'api_key.scopes' => $keyInfo['scopes'], + ]); + } +} + +// Usage +$validator = new ApiKeyValidator([ + 'sk_live_abc123' => ['name' => 'Production', 'scopes' => ['read', 'write']], +]); +``` + +### AuthorizationResult + +Factory methods for different outcomes: + +```php +// Allow access with attributes +AuthorizationResult::allow(['user_id' => '123']); + +// Deny - missing/invalid token (401) +AuthorizationResult::unauthorized('invalid_token', 'Token expired'); + +// Deny - valid token but insufficient permissions (403) +AuthorizationResult::forbidden('insufficient_scope', 'Requires admin scope', ['admin']); + +// Deny - malformed request (400) +AuthorizationResult::badRequest('invalid_request', 'Malformed header'); +``` + +## Scope-Based Access Control + +### Dynamic Scope Requirements + +Use a scope provider callback for per-request scope requirements: + +```php +$authMiddleware = new AuthorizationMiddleware( + metadata: $metadata, + validator: $validator, + scopeProvider: function (ServerRequestInterface $request): array { + // Require different scopes based on the request + $path = $request->getUri()->getPath(); + + if (str_starts_with($path, '/admin')) { + return ['mcp:admin']; + } + + return ['mcp:read']; + }, +); +``` + +### Checking Scopes in Handlers + +```php +#[McpTool(name: 'admin_action')] +public function adminAction(RequestContext $context): array +{ + $scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? []; + + if (!in_array('mcp:admin', $scopes, true)) { + throw new \RuntimeException('Admin scope required'); + } + + // Perform admin action + return ['status' => 'success']; +} +``` + +### Using JwtTokenValidator::requireScopes + +```php +// In a custom middleware or handler +$result = $validator->validate($request, $token); + +if ($result->isAllowed()) { + // Check for specific scopes + $result = $validator->requireScopes($result, ['mcp:write']); +} + +if (!$result->isAllowed()) { + // Handle insufficient scope (returns 403) +} +``` + +## Examples + +Complete working examples are available in the `examples/server/` directory: + +### Keycloak Example + +```bash +cd examples/server/oauth-keycloak +docker-compose up -d + +# Test credentials: demo / demo123 +``` + +See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md) + +### Microsoft Entra ID Example + +```bash +cd examples/server/oauth-microsoft +cp env.example .env +# Edit .env with your Azure credentials +docker-compose up -d +``` + +See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md) + +## Security Considerations + +1. **Always use HTTPS** in production for token transmission +2. **Validate audience claims** to prevent token confusion attacks +3. **Use short-lived tokens** and implement token refresh +4. **Cache JWKS** to reduce latency but allow for key rotation +5. **Never log tokens** - log only non-sensitive claims like subject +6. **Validate scopes** before performing sensitive operations + +## Troubleshooting + +### "Invalid issuer" error + +The `iss` claim in the token must exactly match the configured issuer URL, including trailing slashes. + +### "Invalid audience" error + +Check the `aud` claim matches your configured audience. Some providers use the client ID, others use a custom URI. + +### JWKS fetch timeout + +- Ensure network connectivity to the authorization server +- Consider using a cache to reduce dependency on the auth server +- Check firewall rules allow outbound HTTPS + +### Token expired + +- Check clock synchronization between servers +- Tokens typically have a 5-minute clock skew tolerance +- Ensure clients refresh tokens before expiration diff --git a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php index 9b323de8..f389430c 100644 --- a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php +++ b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php @@ -143,7 +143,7 @@ private function isTrustedGraphIssuer(string $issuer): bool { $host = parse_url($issuer, \PHP_URL_HOST); - return in_array($host, $this->trustedGraphIssuers, true); + return \in_array($host, $this->trustedGraphIssuers, true); } /** diff --git a/src/Server/Transport/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Middleware/AuthorizationMiddleware.php index dd24c30a..51950d81 100644 --- a/src/Server/Transport/Middleware/AuthorizationMiddleware.php +++ b/src/Server/Transport/Middleware/AuthorizationMiddleware.php @@ -44,13 +44,13 @@ final class AuthorizationMiddleware implements MiddlewareInterface private $scopeProvider; /** - * @param ProtectedResourceMetadata $metadata The protected resource metadata to serve - * @param AuthorizationTokenValidatorInterface $validator Token validator implementation - * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) - * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) - * @param list $metadataPaths Paths where metadata should be served (e.g., ["/.well-known/oauth-protected-resource"]) - * @param string|null $resourceMetadataUrl Explicit URL for the resource_metadata in WWW-Authenticate - * @param callable(ServerRequestInterface): list|null $scopeProvider Optional callback to determine required scopes per request + * @param ProtectedResourceMetadata $metadata The protected resource metadata to serve + * @param AuthorizationTokenValidatorInterface $validator Token validator implementation + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * @param list $metadataPaths Paths where metadata should be served (e.g., ["/.well-known/oauth-protected-resource"]) + * @param string|null $resourceMetadataUrl Explicit URL for the resource_metadata in WWW-Authenticate + * @param callable(ServerRequestInterface): list|null $scopeProvider Optional callback to determine required scopes per request */ public function __construct( private ProtectedResourceMetadata $metadata, @@ -109,7 +109,7 @@ private function isMetadataRequest(ServerRequestInterface $request): bool return false; } - return in_array($request->getUri()->getPath(), $this->metadataPaths, true); + return \in_array($request->getUri()->getPath(), $this->metadataPaths, true); } private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface @@ -131,29 +131,29 @@ private function buildAuthenticateHeader(ServerRequestInterface $request, Author // Include resource_metadata URL per RFC 9728 $resourceMetadataUrl = $this->resolveResourceMetadataUrl($request); if (null !== $resourceMetadataUrl) { - $parts[] = 'resource_metadata="' . $this->escapeHeaderValue($resourceMetadataUrl) . '"'; + $parts[] = 'resource_metadata="'.$this->escapeHeaderValue($resourceMetadataUrl).'"'; } // Include scope hint per RFC 6750 Section 3 $scopes = $this->resolveScopes($request, $result); if (!empty($scopes)) { - $parts[] = 'scope="' . $this->escapeHeaderValue(implode(' ', $scopes)) . '"'; + $parts[] = 'scope="'.$this->escapeHeaderValue(implode(' ', $scopes)).'"'; } // Include error details if (null !== $result->getError()) { - $parts[] = 'error="' . $this->escapeHeaderValue($result->getError()) . '"'; + $parts[] = 'error="'.$this->escapeHeaderValue($result->getError()).'"'; } if (null !== $result->getErrorDescription()) { - $parts[] = 'error_description="' . $this->escapeHeaderValue($result->getErrorDescription()) . '"'; + $parts[] = 'error_description="'.$this->escapeHeaderValue($result->getErrorDescription()).'"'; } if (empty($parts)) { return 'Bearer'; } - return 'Bearer ' . implode(', ', $parts); + return 'Bearer '.implode(', ', $parts); } /** @@ -222,10 +222,10 @@ private function resolveResourceMetadataUrl(ServerRequestInterface $request): ?s $port = $uri->getPort(); if (null !== $port && !$this->isDefaultPort($scheme, $port)) { - $authority .= ':' . $port; + $authority .= ':'.$port; } - return $scheme . '://' . $authority . $this->metadataPaths[0]; + return $scheme.'://'.$authority.$this->metadataPaths[0]; } private function isDefaultPort(string $scheme, int $port): bool @@ -260,7 +260,7 @@ private function normalizePaths(array $paths): array continue; } if ('/' !== $path[0]) { - $path = '/' . $path; + $path = '/'.$path; } $normalized[] = $path; } diff --git a/src/Server/Transport/Middleware/AuthorizationResult.php b/src/Server/Transport/Middleware/AuthorizationResult.php index f3955a54..c1600bc4 100644 --- a/src/Server/Transport/Middleware/AuthorizationResult.php +++ b/src/Server/Transport/Middleware/AuthorizationResult.php @@ -25,7 +25,7 @@ final class AuthorizationResult { /** - * @param list|null $scopes Scopes to include in WWW-Authenticate challenge + * @param list|null $scopes Scopes to include in WWW-Authenticate challenge * @param array $attributes Attributes to attach to the request on success */ private function __construct( @@ -53,16 +53,15 @@ public static function allow(array $attributes = []): self * * Use when no valid credentials are provided or the token is invalid. * - * @param string|null $error OAuth error code (e.g., "invalid_token") - * @param string|null $errorDescription Human-readable error description - * @param list|null $scopes Required scopes to include in challenge + * @param string|null $error OAuth error code (e.g., "invalid_token") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge */ public static function unauthorized( ?string $error = null, ?string $errorDescription = null, ?array $scopes = null, - ): self - { + ): self { return new self(false, 401, $error, $errorDescription, $scopes, []); } @@ -71,16 +70,15 @@ public static function unauthorized( * * Use when the token is valid but lacks required permissions/scopes. * - * @param string|null $error OAuth error code (defaults to "insufficient_scope") - * @param string|null $errorDescription Human-readable error description - * @param list|null $scopes Required scopes to include in challenge + * @param string|null $error OAuth error code (defaults to "insufficient_scope") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge */ public static function forbidden( ?string $error = 'insufficient_scope', ?string $errorDescription = null, ?array $scopes = null, - ): self - { + ): self { return new self(false, 403, $error ?? 'insufficient_scope', $errorDescription, $scopes, []); } @@ -89,14 +87,13 @@ public static function forbidden( * * Use when the Authorization header is malformed. * - * @param string|null $error OAuth error code (defaults to "invalid_request") + * @param string|null $error OAuth error code (defaults to "invalid_request") * @param string|null $errorDescription Human-readable error description */ public static function badRequest( ?string $error = 'invalid_request', ?string $errorDescription = null, - ): self - { + ): self { return new self(false, 400, $error ?? 'invalid_request', $errorDescription, null, []); } diff --git a/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php b/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php index 6c315e72..70a2043c 100644 --- a/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php +++ b/src/Server/Transport/Middleware/AuthorizationTokenValidatorInterface.php @@ -26,8 +26,8 @@ interface AuthorizationTokenValidatorInterface /** * Validates an access token extracted from the Authorization header. * - * @param ServerRequestInterface $request The incoming HTTP request - * @param string $accessToken The bearer token (without "Bearer " prefix) + * @param ServerRequestInterface $request The incoming HTTP request + * @param string $accessToken The bearer token (without "Bearer " prefix) * * @return AuthorizationResult The result of the validation */ diff --git a/src/Server/Transport/Middleware/JwtTokenValidator.php b/src/Server/Transport/Middleware/JwtTokenValidator.php index 076af516..9f81be87 100644 --- a/src/Server/Transport/Middleware/JwtTokenValidator.php +++ b/src/Server/Transport/Middleware/JwtTokenValidator.php @@ -46,15 +46,15 @@ class JwtTokenValidator implements AuthorizationTokenValidatorInterface private const CACHE_KEY_PREFIX = 'mcp_jwt_jwks_'; /** - * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") For Microsoft Entra ID, you may need to provide both v1.0 and v2.0 issuers - * @param string|list $audience Expected audience(s) for the token - * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") For Microsoft Entra ID, you may need to provide both v1.0 and v2.0 issuers + * @param string|list $audience Expected audience(s) for the token + * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param CacheInterface|null $cache PSR-16 cache for JWKS (optional) - * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) - * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) - * @param string $scopeClaim Claim name for scopes (default: "scope") + * @param CacheInterface|null $cache PSR-16 cache for JWKS (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) + * @param string $scopeClaim Claim name for scopes (default: "scope") */ public function __construct( private readonly string|array $issuer, @@ -76,7 +76,7 @@ public function validate(ServerRequestInterface $request, string $accessToken): // Decode header to see key ID $parts = explode('.', $accessToken); $header = null; - if (count($parts) >= 2) { + if (\count($parts) >= 2) { $header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true); } @@ -90,7 +90,7 @@ public function validate(ServerRequestInterface $request, string $accessToken): $keys = $this->getJwks(); $decoded = JWT::decode($accessToken, $keys); /** @var array $claims */ - $claims = (array)$decoded; + $claims = (array) $decoded; // Validate issuer if (!$this->validateIssuer($claims)) { @@ -154,12 +154,12 @@ public function validate(ServerRequestInterface $request, string $accessToken): * * This method performs claim-based validation without signature verification. * - * @param string $accessToken The JWT access token - * @param array $parts Token parts (header, payload, signature) + * @param string $accessToken The JWT access token + * @param array $parts Token parts (header, payload, signature) */ private function validateGraphToken(string $accessToken, array $parts): AuthorizationResult { - if (count($parts) < 2) { + if (\count($parts) < 2) { return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); } @@ -219,8 +219,8 @@ private function validateGraphToken(string $accessToken, array $parts): Authoriz * * Use this after validation to check specific scope requirements. * - * @param AuthorizationResult $result The result from validate() - * @param list $requiredScopes Scopes required for this operation + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation * * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise */ @@ -240,7 +240,7 @@ public function requireScopes(AuthorizationResult $result, array $requiredScopes if (!\in_array($required, $tokenScopes, true)) { return AuthorizationResult::forbidden( 'insufficient_scope', - sprintf('Required scope: %s', $required), + \sprintf('Required scope: %s', $required), $requiredScopes ); } @@ -255,7 +255,7 @@ public function requireScopes(AuthorizationResult $result, array $requiredScopes private function getJwks(): array { $jwksUri = $this->resolveJwksUri(); - $cacheKey = self::CACHE_KEY_PREFIX . hash('sha256', $jwksUri); + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri); $jwksData = null; @@ -275,7 +275,7 @@ private function getJwks(): array } } - /** @var array */ + /* @var array */ return JWK::parseKeySet($jwksData, $this->algorithms[0]); } @@ -325,11 +325,7 @@ private function fetchJwks(string $jwksUri): array $response = $this->httpClient->sendRequest($request); if (200 !== $response->getStatusCode()) { - throw new RuntimeException(sprintf( - 'Failed to fetch JWKS from %s: HTTP %d', - $jwksUri, - $response->getStatusCode() - )); + throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode())); } $body = $response->getBody()->__toString(); @@ -337,14 +333,14 @@ private function fetchJwks(string $jwksUri): array try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); } if (!\is_array($data) || !isset($data['keys'])) { throw new RuntimeException('Invalid JWKS format: missing "keys" array.'); } - /** @var array $data */ + /* @var array $data */ return $data; } diff --git a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php index 0f5c94c6..1489c073 100644 --- a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php @@ -45,9 +45,9 @@ final class OAuthProxyMiddleware implements MiddlewareInterface private ?array $upstreamMetadata = null; /** - * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider - * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) - * @param string|null $clientSecret Optional client secret for confidential clients + * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider + * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) + * @param string|null $clientSecret Optional client secret for confidential clients */ public function __construct( private readonly string $upstreamIssuer, @@ -100,7 +100,7 @@ private function handleAuthorize(ServerRequestInterface $request): ResponseInter $rawQueryString = $request->getUri()->getQuery(); // Build upstream URL preserving exact query string - $upstreamUrl = $authorizationEndpoint . '?' . $rawQueryString; + $upstreamUrl = $authorizationEndpoint.'?'.$rawQueryString; // Redirect to upstream authorization server return $this->responseFactory @@ -152,7 +152,7 @@ private function handleToken(ServerRequestInterface $request): ResponseInterface ->withHeader('Cache-Control', 'no-store') ->withBody($this->streamFactory->createStream($responseBody)); } catch (\Throwable $e) { - return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: ' . $e->getMessage()); + return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: '.$e->getMessage()); } } @@ -163,8 +163,8 @@ private function createAuthServerMetadataResponse(): ResponseInterface // Create local metadata that points to our proxy endpoints $localMetadata = [ 'issuer' => $this->upstreamIssuer, - 'authorization_endpoint' => rtrim($this->localBaseUrl, '/') . '/authorize', - 'token_endpoint' => rtrim($this->localBaseUrl, '/') . '/token', + 'authorization_endpoint' => rtrim($this->localBaseUrl, '/').'/authorize', + 'token_endpoint' => rtrim($this->localBaseUrl, '/').'/token', 'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'], 'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'], 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], @@ -198,8 +198,8 @@ private function getUpstreamMetadata(): array // Try OpenID Connect discovery first $discoveryUrls = [ - rtrim($this->upstreamIssuer, '/') . '/.well-known/openid-configuration', - rtrim($this->upstreamIssuer, '/') . '/.well-known/oauth-authorization-server', + rtrim($this->upstreamIssuer, '/').'/.well-known/openid-configuration', + rtrim($this->upstreamIssuer, '/').'/.well-known/oauth-authorization-server', ]; foreach ($discoveryUrls as $url) { diff --git a/src/Server/Transport/Middleware/OidcDiscovery.php b/src/Server/Transport/Middleware/OidcDiscovery.php index 4721c24b..fa6f484e 100644 --- a/src/Server/Transport/Middleware/OidcDiscovery.php +++ b/src/Server/Transport/Middleware/OidcDiscovery.php @@ -37,10 +37,10 @@ class OidcDiscovery private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; /** - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) - * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) */ public function __construct( ?ClientInterface $httpClient = null, @@ -68,7 +68,7 @@ public function __construct( */ public function discover(string $issuer): array { - $cacheKey = self::CACHE_KEY_PREFIX . hash('sha256', $issuer); + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $issuer); if (null !== $this->cache) { $cached = $this->cache->get($cacheKey); @@ -119,7 +119,7 @@ public function fetchJwks(string $issuer): array { $jwksUri = $this->getJwksUri($issuer); - $cacheKey = self::CACHE_KEY_PREFIX . 'jwks_' . hash('sha256', $jwksUri); + $cacheKey = self::CACHE_KEY_PREFIX.'jwks_'.hash('sha256', $jwksUri); if (null !== $this->cache) { $cached = $this->cache->get($cacheKey); @@ -204,15 +204,15 @@ private function fetchMetadata(string $issuer): array $parsed = parse_url($issuer); if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { - throw new \RuntimeException(sprintf('Invalid issuer URL: %s', $issuer)); + throw new \RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); } $scheme = $parsed['scheme']; $host = $parsed['host']; - $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + $port = isset($parsed['port']) ? ':'.$parsed['port'] : ''; $path = $parsed['path'] ?? ''; - $baseUrl = $scheme . '://' . $host . $port; + $baseUrl = $scheme.'://'.$host.$port; // Build discovery URLs in priority order per RFC 8414 Section 3.1 $discoveryUrls = []; @@ -220,15 +220,15 @@ private function fetchMetadata(string $issuer): array if ('' !== $path && '/' !== $path) { // For issuer URLs with path components // 1. OAuth 2.0 path insertion - $discoveryUrls[] = $baseUrl . '/.well-known/oauth-authorization-server' . $path; + $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'.$path; // 2. OIDC path insertion - $discoveryUrls[] = $baseUrl . '/.well-known/openid-configuration' . $path; + $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; // 3. OIDC path appending - $discoveryUrls[] = $issuer . '/.well-known/openid-configuration'; + $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; } else { // For issuer URLs without path components - $discoveryUrls[] = $baseUrl . '/.well-known/oauth-authorization-server'; - $discoveryUrls[] = $baseUrl . '/.well-known/openid-configuration'; + $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; + $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'; } $lastException = null; @@ -249,11 +249,7 @@ private function fetchMetadata(string $issuer): array } } - throw new \RuntimeException( - sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), - 0, - $lastException - ); + throw new \RuntimeException(\sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), 0, $lastException); } /** @@ -267,11 +263,7 @@ private function fetchJson(string $url): array $response = $this->httpClient->sendRequest($request); if ($response->getStatusCode() >= 400) { - throw new \RuntimeException(sprintf( - 'HTTP request to %s failed with status %d', - $url, - $response->getStatusCode() - )); + throw new \RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); } $body = $response->getBody()->__toString(); @@ -279,11 +271,11 @@ private function fetchJson(string $url): array try { $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new \RuntimeException(sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); + throw new \RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); } if (!\is_array($data)) { - throw new \RuntimeException(sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); + throw new \RuntimeException(\sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); } return $data; diff --git a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php index 067efa05..87207a84 100644 --- a/src/Server/Transport/Middleware/ProtectedResourceMetadata.php +++ b/src/Server/Transport/Middleware/ProtectedResourceMetadata.php @@ -24,10 +24,10 @@ class ProtectedResourceMetadata { /** - * @param list $authorizationServers URLs of authorization servers that can issue tokens for this resource - * @param list|null $scopesSupported OAuth scopes supported by this resource - * @param string|null $resource The resource identifier (typically the resource's URL) - * @param array $extra Additional metadata fields + * @param list $authorizationServers URLs of authorization servers that can issue tokens for this resource + * @param list|null $scopesSupported OAuth scopes supported by this resource + * @param string|null $resource The resource identifier (typically the resource's URL) + * @param array $extra Additional metadata fields */ public function __construct( private readonly array $authorizationServers, diff --git a/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php index 95cfbcc2..0e62a6c2 100644 --- a/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php +++ b/tests/Unit/Server/Transport/Middleware/JwtTokenValidatorTest.php @@ -182,7 +182,7 @@ public function testGraphTokenWithNonceHeaderIsAllowed(): void 'exp' => time() + 600, ], \JSON_THROW_ON_ERROR)); - $token = $header . '.' . $payload . '.'; + $token = $header.'.'.$payload.'.'; $validator = new JwtTokenValidator( issuer: ['https://auth.example.com'], @@ -509,7 +509,7 @@ public function testGraphTokenInvalidFormatIsUnauthorized(): void // Trigger the Graph token path (nonce in header) with an empty payload segment. // This makes validateGraphToken() run and fail decoding the payload. - $token = $header . '..'; + $token = $header.'..'; $validator = new JwtTokenValidator( issuer: ['https://auth.example.com'], @@ -548,7 +548,7 @@ public function testGraphTokenInvalidIssuerIsUnauthorized(): void 'exp' => time() + 600, ], \JSON_THROW_ON_ERROR)); - $token = $header . '.' . $payload . '.'; + $token = $header.'.'.$payload.'.'; $validator = new JwtTokenValidator( issuer: ['https://auth.example.com'], @@ -640,7 +640,7 @@ private function unsignedJwt(array $claims): string $header = $this->b64urlEncode(json_encode(['alg' => 'none', 'typ' => 'JWT'], \JSON_THROW_ON_ERROR)); $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); - return $header . '.' . $payload . '.'; + return $header.'.'.$payload.'.'; } /** @@ -649,22 +649,22 @@ private function unsignedJwt(array $claims): string private function generateRsaKeypairAsJwk(string $kid): array { $key = openssl_pkey_new([ - 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048, ]); if (false === $key) { - self::fail('Failed to generate RSA keypair via OpenSSL.'); + $this->fail('Failed to generate RSA keypair via OpenSSL.'); } $privateKeyPem = ''; if (!openssl_pkey_export($key, $privateKeyPem)) { - self::fail('Failed to export RSA private key.'); + $this->fail('Failed to export RSA private key.'); } $details = openssl_pkey_get_details($key); if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { - self::fail('Failed to read RSA key details.'); + $this->fail('Failed to read RSA key details.'); } $n = $this->b64urlEncode($details['rsa']['n']); @@ -692,7 +692,7 @@ private function b64urlEncode(string $data): string */ private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface { - $expectedCalls ??= count($responses); + $expectedCalls ??= \count($responses); $httpClient = $this->createMock(ClientInterface::class); $expectation = $httpClient @@ -705,7 +705,7 @@ private function createHttpClientMock(array $responses, ?int $expectedCalls = nu } else { // If expectedCalls > count(responses), keep returning the last response. $sequence = $responses; - while (count($sequence) < $expectedCalls) { + while (\count($sequence) < $expectedCalls) { $sequence[] = $responses[array_key_last($responses)]; } $expectation->willReturnOnConsecutiveCalls(...$sequence); From 637289fed565cf9f6f28d23bb7c0d1d2c9f4a9e5 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 2 Mar 2026 13:28:46 +0100 Subject: [PATCH 18/18] Fix PHPStan typing issues in OAuth middleware --- src/Server/Transport/Http/OAuth/JwksProvider.php | 3 +++ .../Transport/Middleware/AuthorizationMiddleware.php | 6 ++---- .../Transport/Middleware/OAuthProxyMiddleware.php | 11 ++++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Server/Transport/Http/OAuth/JwksProvider.php b/src/Server/Transport/Http/OAuth/JwksProvider.php index 47a59a39..794ce3b3 100644 --- a/src/Server/Transport/Http/OAuth/JwksProvider.php +++ b/src/Server/Transport/Http/OAuth/JwksProvider.php @@ -78,6 +78,9 @@ public function getJwks(string $issuer, ?string $jwksUri = null): array return $jwks; } + /** + * @return array + */ private function fetchJwks(string $jwksUri): array { $request = $this->requestFactory->createRequest('GET', $jwksUri) diff --git a/src/Server/Transport/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Middleware/AuthorizationMiddleware.php index 51950d81..aa118be0 100644 --- a/src/Server/Transport/Middleware/AuthorizationMiddleware.php +++ b/src/Server/Transport/Middleware/AuthorizationMiddleware.php @@ -117,14 +117,12 @@ private function buildErrorResponse(ServerRequestInterface $request, Authorizati $response = $this->responseFactory->createResponse($result->getStatusCode()); $header = $this->buildAuthenticateHeader($request, $result); - if (null !== $header) { - $response = $response->withHeader('WWW-Authenticate', $header); - } + $response = $response->withHeader('WWW-Authenticate', $header); return $response; } - private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): ?string + private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): string { $parts = []; diff --git a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php index 1489c073..16c5c9f3 100644 --- a/src/Server/Transport/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Middleware/OAuthProxyMiddleware.php @@ -42,6 +42,7 @@ final class OAuthProxyMiddleware implements MiddlewareInterface private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; + /** @var array|null */ private ?array $upstreamMetadata = null; /** @@ -190,6 +191,9 @@ private function createAuthServerMetadataResponse(): ResponseInterface ->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_UNESCAPED_SLASHES))); } + /** + * @return array + */ private function getUpstreamMetadata(): array { if (null !== $this->upstreamMetadata) { @@ -208,7 +212,12 @@ private function getUpstreamMetadata(): array $response = $this->httpClient->sendRequest($request); if (200 === $response->getStatusCode()) { - $this->upstreamMetadata = json_decode($response->getBody()->__toString(), true) ?? []; + $decoded = json_decode($response->getBody()->__toString(), true); + if (!\is_array($decoded)) { + continue; + } + + $this->upstreamMetadata = $decoded; return $this->upstreamMetadata; }