php-sdk/examples/server/oauth-keycloak
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Volodymyr Panivko 293a5880e4
[Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory (#307)
* [Server] Add CORS, DNS rebinding and protocol version middleware

Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed
through a public `StreamableHttpTransport::defaultMiddleware()` factory
composed automatically when no middleware is passed.

- `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`),
  configurable allowlist, reflects matching origin with `Vary: Origin`
  to protect shared caches.
- `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against
  a hostname allowlist (localhost-only by default).
- `ProtocolVersionMiddleware`: rejects requests carrying an unsupported
  `Mcp-Protocol-Version` header with `400 Bad Request`.

The transport no longer applies CORS via an `instanceof + array_unshift`
post-hook; the middleware parameter is nullable — `null` installs the
secure defaults, `[]` disables them, and users compose by spreading
`StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and
`PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware
can reuse them.

BC breaks:
- The `corsHeaders` constructor parameter is removed; the `middleware`
  parameter shifts one position. Positional callers passing the old
  `corsHeaders` argument must switch to named arguments or drop it.
- Default `Access-Control-Allow-Origin` is no longer `*`.

Addresses #260 (DNS rebinding), #277 (CORS extraction) and #306
(protocol version validation).

* [Server] Address review feedback on middleware defaults

- CorsMiddleware: emit Access-Control-Allow-Methods/Headers only on
  preflight per CORS spec; tokenize Vary header to avoid substring
  false-positives; add allowCredentials flag that emits
  Access-Control-Allow-Credentials and throws when combined with
  wildcard origin.
- DnsRebindingProtectionMiddleware: return plain-text 403 body
  (HTTP-level rejection, not JSON-RPC); use parse_url(..., PHP_URL_HOST)
  for origin parsing; drop redundant unbracketed '::1' from default
  allowlist.
- ProtocolVersionMiddleware: use Error::forInvalidParams instead of
  forInvalidRequest -- JSON-RPC -32602 fits header-value rejection
  better.
- JsonRpcErrorResponse::create now accepts a JsonRpc\Error so callers
  choose the error code.
- StreamableHttpTransport: emit a warning log when an explicit empty
  middleware list is passed, so operators can spot accidental bypass
  of the default security stack.

* [Server] Fix CI: switch warning-log tests to PHPUnit mocks

Replace the anonymous AbstractLogger fixture in
StreamableHttpTransportTest with `$this->createMock(LoggerInterface::class)`
so the test does not declare a `log()` signature at all. That sidesteps
both the psr/log v1 LSP incompatibility (untyped parameter) and the
phpstan `missingType.parameter` complaint in one move, and matches how
the rest of the suite (CallToolHandlerTest etc.) fakes loggers.

* Update src/Server/Transport/Http/Middleware/CorsMiddleware.php

Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>

---------

Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
2026-05-25 13:49:43 +02:00
..
keycloak [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
nginx [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
docker-compose.yml [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
Dockerfile [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
McpElements.php CS update after release of v3.95 (#282) 2026-04-26 12:39:27 +02:00
README.md [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
server.php [Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory (#307) 2026-05-25 13:49:43 +02:00

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:
docker compose up -d
  1. Wait for Keycloak to be ready (may take 30-60 seconds):
docker compose logs -f keycloak
# Wait until you see "Running the server in development mode"
  1. Get an access token:
# 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
  1. Test the MCP server:
# 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"}}}'
  1. Use with MCP Inspector:

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

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

Configuration

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:8180
  • Realm: mcp
  • Audience: mcp-server

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:8180 (Docker network). For local development outside Docker, use http://localhost:8180.

Cleanup

docker compose down -v