[Server] Oauth2 based on middleware (#221)

* Add Middleware handlers to StreamableHttpTransport

* OAuth Implementation based on middleware

* OAuth Implementation based on middleware

* Add Middleware handlers to StreamableHttpTransport

* OAuth Implementation based on middleware

* OAuth Implementation based on middleware

Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>

* Address PR review feedback for OAuth middleware

* Apply php-cs-fixer to satisfy QA

* Use Mcp exception classes in OAuth middleware

* Refactor OAuth HTTP stack and add Microsoft example policies

* Fix QA PHPStan findings in OAuth examples and auth middleware

* Require OIDC discovery in JWKS provider and refine OAuth proxy behavior

* Remove local development docker artifacts from VCS

* Revert .gitignore changes for local docker files

* Fix body retrieval in OAuthRequestMetaMiddleware for proper string conversion

Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>

* Address OAuth PR review feedback and align example configs

* Fix QA style issues and add authorization docs

* Fix PHPStan typing issues in OAuth middleware

* Remove duplicate Middleware namespace, consolidate under Http\Middleware

Remove old Mcp\Server\Transport\Middleware\ classes that were duplicated
in Mcp\Server\Transport\Http\Middleware\ and Http\OAuth\. Update docs
to reference the correct namespaces.

* Address PR review: replace Throwable catch with specific exceptions, add JSON_THROW_ON_ERROR

- JwtTokenValidator: replace catch(\Throwable) with explicit
  \InvalidArgumentException|\UnexpectedValueException|\DomainException,
  letting RuntimeException from JwksProvider propagate as infrastructure errors
- OAuthProxyMiddleware: add \JSON_THROW_ON_ERROR to json_encode for metadata response
- Update tests to expect RuntimeException for JWKS infrastructure failures

---------

Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>
Co-authored-by: Volodymyr Panivko <volodymyr.panivko@togethernetworks.com>
This commit is contained in:
Volodymyr Panivko 2026-03-10 17:10:07 +01:00 • committed by GitHub
commit e3f4b88768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 6038 additions and 0 deletions

View file

@ -0,0 +1,23 @@
FROM php:8.1-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"]

View file

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Example\Server\OAuthKeycloak;
use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Server\RequestContext;
/**
* MCP elements for the OAuth Keycloak example.
*
* These tools demonstrate a protected MCP server.
* All requests must include a valid OAuth bearer token.
*/
final class McpElements
{
/**
* Confirms the user is authenticated.
*
* The fact that this tool executes means the request passed OAuth validation.
*
* @return array<string, mixed>
*/
#[McpTool(
name: 'get_auth_status',
description: 'Confirm authentication status - only accessible with valid OAuth token'
)]
public function getAuthStatus(RequestContext $context): array
{
$meta = $context->getRequest()->getMeta() ?? [];
$oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : [];
$claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : [];
$scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : [];
return [
'authenticated' => true,
'provider' => 'Keycloak',
'message' => 'You have successfully authenticated with OAuth!',
'timestamp' => date('c'),
'user' => [
'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null),
'username' => $claims['preferred_username'] ?? null,
'name' => $claims['name'] ?? null,
'email' => $claims['email'] ?? null,
'issuer' => $claims['iss'] ?? null,
'audience' => $claims['aud'] ?? null,
'scopes' => $scopes,
'expires_at' => isset($claims['exp']) && is_numeric($claims['exp'])
? date('c', (int) $claims['exp'])
: null,
],
'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.',
];
}
/**
* Simulates calling a protected external API.
*
* @return array<string, mixed>
*/
#[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.
*
* @return array<string, mixed>
*/
#[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!',
};
}
}

View file

@ -0,0 +1,135 @@
# 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:**
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
```bash
docker compose down -v
```

View file

@ -0,0 +1,69 @@
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
- --http-port=8180
ports:
- "8180:8180"
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"]
interval: 10s
timeout: 5s
retries: 15
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:8180
KEYCLOAK_REALM: mcp
MCP_AUDIENCE: mcp-server
depends_on:
keycloak:
condition: service_healthy
command: >
sh -c "mkdir -p /app/examples/server/oauth-keycloak/sessions;
chmod -R 0777 /app/examples/server/oauth-keycloak/sessions;
touch /app/examples/server/oauth-keycloak/dev.log;
chmod 0666 /app/examples/server/oauth-keycloak/dev.log;
touch /app/examples/server/dev.log;
chmod 0666 /app/examples/server/dev.log;
composer install --no-interaction --quiet 2>/dev/null || true;
php-fpm"
networks:
- mcp-network
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

View file

@ -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
}
]
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
require_once dirname(__DIR__).'/bootstrap.php';
use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
use Mcp\Server\Transport\StreamableHttpTransport;
$externalIssuer = 'http://localhost:8180/realms/mcp';
$internalIssuer = 'http://keycloak:8180/realms/mcp';
$validator = new JwtTokenValidator(
issuer: [$externalIssuer, $internalIssuer],
audience: 'mcp-server',
jwksProvider: new JwksProvider(new OidcDiscovery()),
jwksUri: $internalIssuer.'/protocol/openid-connect/certs',
);
$protectedResourceMetadata = new ProtectedResourceMetadata(
authorizationServers: [$externalIssuer],
scopesSupported: ['openid'],
resource: 'http://localhost:8000/mcp',
resourceName: 'OAuth Keycloak Example MCP Server',
);
$metadataMiddleware = new ProtectedResourceMetadataMiddleware($protectedResourceMetadata);
$authMiddleware = new AuthorizationMiddleware(
$validator,
$protectedResourceMetadata,
);
$server = Server::builder()
->setServerInfo('OAuth Keycloak Example', '1.0.0')
->setLogger(logger())
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setDiscovery(__DIR__)
->build();
$transport = new StreamableHttpTransport(
(new Psr17Factory())->createServerRequestFromGlobals(),
logger: logger(),
middleware: [$metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()],
);
$response = $server->run($transport);
(new SapiEmitter())->emit($response);