From 6988e5b829211d6455975dd9893569efbe0841b3 Mon Sep 17 00:00:00 2001 From: Volodymyr Panivko Date: Mon, 29 Dec 2025 16:04:50 +0100 Subject: [PATCH 1/2] 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 2/2] 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); + } +}