[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:
parent
61c20f1450
commit
e3f4b88768
45 changed files with 6038 additions and 0 deletions
|
|
@ -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": {
|
||||
"ext-openssl": "*",
|
||||
"firebase/php-jwt": "^6.10 || ^7.0",
|
||||
"laminas/laminas-httphandlerrunner": "^2.12",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"nyholm/psr7-server": "^1.1",
|
||||
|
|
@ -46,6 +49,7 @@
|
|||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||
"symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||
"symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||
"symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0"
|
||||
},
|
||||
"autoload": {
|
||||
|
|
@ -68,6 +72,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/"
|
||||
}
|
||||
|
|
|
|||
430
docs/authorization.md
Normal file
430
docs/authorization.md
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
# Authorization
|
||||
|
||||
The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, implementing the
|
||||
[MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Components](#components)
|
||||
- [JWT Token Validation](#jwt-token-validation)
|
||||
- [Protected Resource Metadata](#protected-resource-metadata)
|
||||
- [Custom Token Validators](#custom-token-validators)
|
||||
- [Scope-Based Access Control](#scope-based-access-control)
|
||||
- [Examples](#examples)
|
||||
|
||||
## Overview
|
||||
|
||||
Authorization in MCP is implemented at the transport level using PSR-15 middleware. The SDK provides:
|
||||
|
||||
- **AuthorizationMiddleware** - PSR-15 middleware that enforces bearer token authentication
|
||||
- **ProtectedResourceMetadataMiddleware** - Serves RFC 9728 metadata at well-known endpoints
|
||||
- **OAuthProxyMiddleware** - Proxies OAuth flows to upstream authorization servers
|
||||
- **OAuthRequestMetaMiddleware** - Bridges HTTP OAuth attributes to JSON-RPC request meta
|
||||
- **JwtTokenValidator** - Validates JWT tokens using JWKS from OAuth 2.0 / OIDC providers
|
||||
- **OidcDiscovery** - Discovers authorization server metadata from well-known endpoints
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌────────────────────┐ ┌─────────────────┐
|
||||
│ MCP Client │────▶│ AuthorizationMiddleware │────▶│ MCP Handlers │
|
||||
└─────────────┘ └────────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
│ │ Validate JWT
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────────┐
|
||||
│ Auth Server │◀────│ JwtTokenValidator│
|
||||
│ (Keycloak, │ │ + JWKS │
|
||||
│ Entra ID) │ └─────────────────┘
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```php
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
|
||||
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;
|
||||
|
||||
// 1. Set up OIDC discovery and JWKS provider
|
||||
$discovery = new OidcDiscovery();
|
||||
$jwksProvider = new JwksProvider($discovery);
|
||||
|
||||
// 2. Create JWT validator for your OAuth provider
|
||||
$validator = new JwtTokenValidator(
|
||||
issuer: 'https://auth.example.com/realms/mcp',
|
||||
audience: 'mcp-server',
|
||||
jwksProvider: $jwksProvider,
|
||||
);
|
||||
|
||||
// 3. Create Protected Resource Metadata (RFC 9728)
|
||||
$metadata = new ProtectedResourceMetadata(
|
||||
authorizationServers: ['https://auth.example.com/realms/mcp'],
|
||||
scopesSupported: ['mcp:read', 'mcp:write'],
|
||||
);
|
||||
|
||||
// 4. Create middleware stack
|
||||
$authMiddleware = new AuthorizationMiddleware(
|
||||
validator: $validator,
|
||||
resourceMetadata: $metadata,
|
||||
);
|
||||
|
||||
$metadataMiddleware = new ProtectedResourceMetadataMiddleware(
|
||||
metadata: $metadata,
|
||||
);
|
||||
|
||||
// 5. Create transport with middleware
|
||||
$transport = new StreamableHttpTransport(
|
||||
$request,
|
||||
middlewares: [$metadataMiddleware, $authMiddleware],
|
||||
);
|
||||
|
||||
// 6. Run server
|
||||
$server = Server::builder()
|
||||
->setServerInfo('Protected MCP Server', '1.0.0')
|
||||
->setDiscovery(__DIR__)
|
||||
->build();
|
||||
|
||||
$response = $server->run($transport);
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### AuthorizationMiddleware
|
||||
|
||||
The main middleware that enforces authentication:
|
||||
|
||||
```php
|
||||
$middleware = new AuthorizationMiddleware(
|
||||
validator: $validator, // AuthorizationTokenValidatorInterface
|
||||
resourceMetadata: $metadata, // ProtectedResourceMetadata instance
|
||||
responseFactory: null, // PSR-17 (auto-discovered)
|
||||
);
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
| Request | Response |
|
||||
|---------|----------|
|
||||
| Missing Authorization header | 401 with `WWW-Authenticate: Bearer resource_metadata="..."` |
|
||||
| Invalid/expired token | 401 with error details |
|
||||
| Valid token | Passes to next handler with OAuth attributes on request |
|
||||
|
||||
### ProtectedResourceMetadataMiddleware
|
||||
|
||||
Serves Protected Resource Metadata at configured well-known paths:
|
||||
|
||||
```php
|
||||
$metadataMiddleware = new ProtectedResourceMetadataMiddleware(
|
||||
metadata: $metadata, // ProtectedResourceMetadata instance
|
||||
responseFactory: null, // PSR-17 (auto-discovered)
|
||||
streamFactory: null, // PSR-17 (auto-discovered)
|
||||
);
|
||||
```
|
||||
|
||||
### JwtTokenValidator
|
||||
|
||||
Validates JWT access tokens:
|
||||
|
||||
```php
|
||||
$validator = new JwtTokenValidator(
|
||||
issuer: 'https://auth.example.com', // Expected issuer claim
|
||||
audience: 'mcp-server', // Expected audience (string or array)
|
||||
jwksProvider: $jwksProvider, // JwksProviderInterface
|
||||
jwksUri: null, // Explicit JWKS URI (auto-discovered)
|
||||
algorithms: ['RS256', 'RS384'], // Allowed algorithms
|
||||
scopeClaim: 'scope', // Claim name for scopes
|
||||
);
|
||||
```
|
||||
|
||||
**Request Attributes:**
|
||||
|
||||
After successful validation, these attributes are added to the request:
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| `oauth.claims` | All JWT claims as array |
|
||||
| `oauth.scopes` | Extracted scopes as array |
|
||||
| `oauth.subject` | The `sub` claim |
|
||||
| `oauth.client_id` | The `client_id` claim (if present) |
|
||||
| `oauth.authorized_party` | The `azp` claim (if present) |
|
||||
|
||||
### ProtectedResourceMetadata
|
||||
|
||||
Represents RFC 9728 Protected Resource Metadata:
|
||||
|
||||
```php
|
||||
$metadata = new ProtectedResourceMetadata(
|
||||
authorizationServers: [ // Required: authorization server URLs
|
||||
'https://auth.example.com',
|
||||
],
|
||||
scopesSupported: [ // Optional: supported scopes
|
||||
'mcp:read',
|
||||
'mcp:write',
|
||||
],
|
||||
resource: 'https://mcp.example.com', // Optional: resource identifier
|
||||
resourceName: 'My MCP Server', // Optional: human-readable name
|
||||
metadataPaths: [ // Paths to serve metadata (default: /.well-known/oauth-protected-resource)
|
||||
'/.well-known/oauth-protected-resource',
|
||||
],
|
||||
extra: [ // Optional: additional fields
|
||||
'custom_field' => 'value',
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
### OidcDiscovery
|
||||
|
||||
Discovers OAuth/OIDC server metadata:
|
||||
|
||||
```php
|
||||
$discovery = new OidcDiscovery(
|
||||
httpClient: null, // PSR-18 (auto-discovered)
|
||||
requestFactory: null, // PSR-17 (auto-discovered)
|
||||
cache: $cache, // PSR-16 cache (optional)
|
||||
cacheTtl: 3600, // Cache TTL
|
||||
);
|
||||
|
||||
// Discover metadata
|
||||
$metadata = $discovery->discover('https://auth.example.com/realms/mcp');
|
||||
|
||||
// Get specific endpoints
|
||||
$jwksUri = $discovery->getJwksUri($issuer);
|
||||
$tokenEndpoint = $discovery->getTokenEndpoint($issuer);
|
||||
$authEndpoint = $discovery->getAuthorizationEndpoint($issuer);
|
||||
```
|
||||
|
||||
### JwksProvider
|
||||
|
||||
Fetches and caches JWKS key sets:
|
||||
|
||||
```php
|
||||
$jwksProvider = new JwksProvider(
|
||||
discovery: $discovery, // OidcDiscoveryInterface
|
||||
httpClient: null, // PSR-18 (auto-discovered)
|
||||
requestFactory: null, // PSR-17 (auto-discovered)
|
||||
cache: $cache, // PSR-16 cache (optional)
|
||||
cacheTtl: 3600, // JWKS cache TTL
|
||||
);
|
||||
```
|
||||
|
||||
## JWT Token Validation
|
||||
|
||||
### Keycloak
|
||||
|
||||
```php
|
||||
$validator = new JwtTokenValidator(
|
||||
issuer: 'https://keycloak.example.com/realms/mcp',
|
||||
audience: 'mcp-server',
|
||||
jwksProvider: $jwksProvider,
|
||||
);
|
||||
```
|
||||
|
||||
### Microsoft Entra ID (Azure AD)
|
||||
|
||||
```php
|
||||
$tenantId = 'your-tenant-id';
|
||||
$clientId = 'your-client-id';
|
||||
|
||||
$validator = new JwtTokenValidator(
|
||||
issuer: "https://login.microsoftonline.com/{$tenantId}/v2.0",
|
||||
audience: $clientId,
|
||||
jwksProvider: $jwksProvider,
|
||||
);
|
||||
```
|
||||
|
||||
### Auth0
|
||||
|
||||
```php
|
||||
$validator = new JwtTokenValidator(
|
||||
issuer: 'https://your-tenant.auth0.com/',
|
||||
audience: 'https://api.example.com',
|
||||
jwksProvider: $jwksProvider,
|
||||
);
|
||||
```
|
||||
|
||||
### Okta
|
||||
|
||||
```php
|
||||
$validator = new JwtTokenValidator(
|
||||
issuer: 'https://your-org.okta.com/oauth2/default',
|
||||
audience: 'api://default',
|
||||
jwksProvider: $jwksProvider,
|
||||
);
|
||||
```
|
||||
|
||||
## Protected Resource Metadata
|
||||
|
||||
The `ProtectedResourceMetadataMiddleware` serves Protected Resource Metadata at configured paths, enabling clients to discover the authorization server:
|
||||
|
||||
```json
|
||||
{
|
||||
"authorization_servers": ["https://auth.example.com/realms/mcp"],
|
||||
"scopes_supported": ["mcp:read", "mcp:write"],
|
||||
"resource": "https://mcp.example.com/mcp"
|
||||
}
|
||||
```
|
||||
|
||||
Clients request this from `/.well-known/oauth-protected-resource` before authenticating.
|
||||
|
||||
### WWW-Authenticate Header
|
||||
|
||||
On 401 responses, the middleware includes:
|
||||
|
||||
```
|
||||
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
|
||||
scope="mcp:read mcp:write"
|
||||
```
|
||||
|
||||
## Custom Token Validators
|
||||
|
||||
Implement `AuthorizationTokenValidatorInterface` for custom validation:
|
||||
|
||||
```php
|
||||
use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
|
||||
use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
|
||||
|
||||
final class ApiKeyValidator implements AuthorizationTokenValidatorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private array $validKeys,
|
||||
) {}
|
||||
|
||||
public function validate(string $accessToken): AuthorizationResult
|
||||
{
|
||||
if (!isset($this->validKeys[$accessToken])) {
|
||||
return AuthorizationResult::unauthorized(
|
||||
'invalid_token',
|
||||
'Unknown API key'
|
||||
);
|
||||
}
|
||||
|
||||
$keyInfo = $this->validKeys[$accessToken];
|
||||
|
||||
return AuthorizationResult::allow([
|
||||
'api_key.name' => $keyInfo['name'],
|
||||
'api_key.scopes' => $keyInfo['scopes'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
$validator = new ApiKeyValidator([
|
||||
'sk_live_abc123' => ['name' => 'Production', 'scopes' => ['read', 'write']],
|
||||
]);
|
||||
```
|
||||
|
||||
### AuthorizationResult
|
||||
|
||||
Factory methods for different outcomes:
|
||||
|
||||
```php
|
||||
// Allow access with attributes
|
||||
AuthorizationResult::allow(['user_id' => '123']);
|
||||
|
||||
// Deny - missing/invalid token (401)
|
||||
AuthorizationResult::unauthorized('invalid_token', 'Token expired');
|
||||
|
||||
// Deny - valid token but insufficient permissions (403)
|
||||
AuthorizationResult::forbidden('insufficient_scope', 'Requires admin scope', ['admin']);
|
||||
|
||||
// Deny - malformed request (400)
|
||||
AuthorizationResult::badRequest('invalid_request', 'Malformed header');
|
||||
```
|
||||
|
||||
## Scope-Based Access Control
|
||||
|
||||
### Checking Scopes in Handlers
|
||||
|
||||
```php
|
||||
#[McpTool(name: 'admin_action')]
|
||||
public function adminAction(RequestContext $context): array
|
||||
{
|
||||
$scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? [];
|
||||
|
||||
if (!in_array('mcp:admin', $scopes, true)) {
|
||||
throw new \RuntimeException('Admin scope required');
|
||||
}
|
||||
|
||||
// Perform admin action
|
||||
return ['status' => 'success'];
|
||||
}
|
||||
```
|
||||
|
||||
### Using JwtTokenValidator::requireScopes
|
||||
|
||||
```php
|
||||
// In a custom middleware or handler
|
||||
$result = $validator->validate($token);
|
||||
|
||||
if ($result->isAllowed()) {
|
||||
// Check for specific scopes
|
||||
$result = $validator->requireScopes($result, ['mcp:write']);
|
||||
}
|
||||
|
||||
if (!$result->isAllowed()) {
|
||||
// Handle insufficient scope (returns 403)
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Complete working examples are available in the `examples/server/` directory:
|
||||
|
||||
### Keycloak Example
|
||||
|
||||
```bash
|
||||
cd examples/server/oauth-keycloak
|
||||
docker-compose up -d
|
||||
|
||||
# Test credentials: demo / demo123
|
||||
```
|
||||
|
||||
See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md)
|
||||
|
||||
### Microsoft Entra ID Example
|
||||
|
||||
```bash
|
||||
cd examples/server/oauth-microsoft
|
||||
cp env.example .env
|
||||
# Edit .env with your Azure credentials
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Always use HTTPS** in production for token transmission
|
||||
2. **Validate audience claims** to prevent token confusion attacks
|
||||
3. **Use short-lived tokens** and implement token refresh
|
||||
4. **Cache JWKS** to reduce latency but allow for key rotation
|
||||
5. **Never log tokens** - log only non-sensitive claims like subject
|
||||
6. **Validate scopes** before performing sensitive operations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid issuer" error
|
||||
|
||||
The `iss` claim in the token must exactly match the configured issuer URL, including trailing slashes.
|
||||
|
||||
### "Invalid audience" error
|
||||
|
||||
Check the `aud` claim matches your configured audience. Some providers use the client ID, others use a custom URI.
|
||||
|
||||
### JWKS fetch timeout
|
||||
|
||||
- Ensure network connectivity to the authorization server
|
||||
- Consider using a cache to reduce dependency on the auth server
|
||||
- Check firewall rules allow outbound HTTPS
|
||||
|
||||
### Token expired
|
||||
|
||||
- Check clock synchronization between servers
|
||||
- Tokens typically have a 5-minute clock skew tolerance
|
||||
- Ensure clients refresh tokens before expiration
|
||||
23
examples/server/oauth-keycloak/Dockerfile
Normal file
23
examples/server/oauth-keycloak/Dockerfile
Normal 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"]
|
||||
134
examples/server/oauth-keycloak/McpElements.php
Normal file
134
examples/server/oauth-keycloak/McpElements.php
Normal 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!',
|
||||
};
|
||||
}
|
||||
}
|
||||
135
examples/server/oauth-keycloak/README.md
Normal file
135
examples/server/oauth-keycloak/README.md
Normal 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
|
||||
```
|
||||
69
examples/server/oauth-keycloak/docker-compose.yml
Normal file
69
examples/server/oauth-keycloak/docker-compose.yml
Normal 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
|
||||
128
examples/server/oauth-keycloak/keycloak/mcp-realm.json
Normal file
128
examples/server/oauth-keycloak/keycloak/mcp-realm.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
25
examples/server/oauth-keycloak/nginx/default.conf
Normal file
25
examples/server/oauth-keycloak/nginx/default.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
68
examples/server/oauth-keycloak/server.php
Normal file
68
examples/server/oauth-keycloak/server.php
Normal 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);
|
||||
3
examples/server/oauth-microsoft/.env.dist
Normal file
3
examples/server/oauth-microsoft/.env.dist
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
AZURE_TENANT_ID=
|
||||
AZURE_CLIENT_ID=
|
||||
AZURE_CLIENT_SECRET=
|
||||
23
examples/server/oauth-microsoft/Dockerfile
Normal file
23
examples/server/oauth-microsoft/Dockerfile
Normal 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"]
|
||||
154
examples/server/oauth-microsoft/McpElements.php
Normal file
154
examples/server/oauth-microsoft/McpElements.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?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\OAuthMicrosoft;
|
||||
|
||||
use Mcp\Capability\Attribute\McpPrompt;
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Server\RequestContext;
|
||||
|
||||
/**
|
||||
* MCP elements for the OAuth Microsoft example.
|
||||
*
|
||||
* These tools demonstrate a protected MCP server using Microsoft Entra ID.
|
||||
* All requests must include a valid Microsoft-issued OAuth bearer token.
|
||||
*/
|
||||
final class McpElements
|
||||
{
|
||||
/**
|
||||
* Confirms the user is authenticated with Microsoft.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[McpTool(
|
||||
name: 'get_auth_status',
|
||||
description: 'Confirm Microsoft Entra ID authentication status'
|
||||
)]
|
||||
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' => 'Microsoft Entra ID',
|
||||
'message' => 'You have successfully authenticated with Microsoft!',
|
||||
'timestamp' => date('c'),
|
||||
'user' => [
|
||||
'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null),
|
||||
'object_id' => $oauth['oauth.object_id'] ?? ($claims['oid'] ?? null),
|
||||
'username' => $claims['preferred_username'] ?? ($claims['upn'] ?? null),
|
||||
'name' => $oauth['oauth.name'] ?? ($claims['name'] ?? null),
|
||||
'email' => $claims['email'] ?? null,
|
||||
'issuer' => $claims['iss'] ?? null,
|
||||
'audience' => $claims['aud'] ?? null,
|
||||
'tenant_id' => $claims['tid'] ?? null,
|
||||
'scopes' => $scopes,
|
||||
'expires_at' => isset($claims['exp']) && is_numeric($claims['exp'])
|
||||
? date('c', (int) $claims['exp'])
|
||||
: null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates calling Microsoft Graph API.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[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.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[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(static 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.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
187
examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php
Normal file
187
examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* 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\OAuthMicrosoft;
|
||||
|
||||
use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
|
||||
use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
|
||||
|
||||
/**
|
||||
* Validates Microsoft Entra ID access tokens.
|
||||
*
|
||||
* This validator supports two Microsoft token modes:
|
||||
* - Standard JWT validation via JWKS (delegated/client credentials tokens)
|
||||
* - Claim-based Microsoft Graph token validation for tokens with a "nonce" header
|
||||
*
|
||||
* Security notice:
|
||||
* Tokens treated as Graph tokens (header contains "nonce") are validated by
|
||||
* claims only in this example and are NOT signature-verified.
|
||||
* This mode is intended for local/demo interoperability only and should not be
|
||||
* used as-is in production deployments.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
class MicrosoftJwtTokenValidator implements AuthorizationTokenValidatorInterface
|
||||
{
|
||||
/**
|
||||
* @param JwtTokenValidator $jwtTokenValidator Base JWT validator used for non-Graph tokens
|
||||
* @param string $scopeClaim Claim name for scopes in Graph tokens
|
||||
* @param list<string> $trustedGraphIssuers Allowed Graph issuer host markers
|
||||
* @param int $notBeforeLeewaySeconds Allowed clock skew for "nbf" claim
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly JwtTokenValidator $jwtTokenValidator,
|
||||
private readonly string $scopeClaim = 'scp',
|
||||
private readonly array $trustedGraphIssuers = ['sts.windows.net', 'login.microsoftonline.com'],
|
||||
private readonly int $notBeforeLeewaySeconds = 60,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(string $accessToken): AuthorizationResult
|
||||
{
|
||||
$parts = explode('.', $accessToken);
|
||||
if (!$this->isGraphToken($parts)) {
|
||||
return $this->jwtTokenValidator->validate($accessToken);
|
||||
}
|
||||
|
||||
return $this->validateGraphToken($parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a token has the required scopes.
|
||||
*
|
||||
* Use this after validation to check specific scope requirements.
|
||||
*
|
||||
* @param AuthorizationResult $result The result from validate()
|
||||
* @param list<string> $requiredScopes Scopes required for this operation
|
||||
*
|
||||
* @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise
|
||||
*/
|
||||
public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult
|
||||
{
|
||||
return $this->jwtTokenValidator->requireScopes($result, $requiredScopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $parts
|
||||
*/
|
||||
private function isGraphToken(array $parts): bool
|
||||
{
|
||||
if ([] === $parts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$header = $this->decodePartToArray($parts[0]);
|
||||
if (null === $header) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($header['nonce']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $parts
|
||||
*/
|
||||
private function validateGraphToken(array $parts): AuthorizationResult
|
||||
{
|
||||
// Intentionally claim-based only for example Graph token compatibility.
|
||||
if (\count($parts) < 2) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.');
|
||||
}
|
||||
|
||||
$payload = $this->decodePartToArray($parts[1]);
|
||||
if (null === $payload) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.');
|
||||
}
|
||||
|
||||
if (isset($payload['exp']) && is_numeric($payload['exp']) && (int) $payload['exp'] < time()) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.');
|
||||
}
|
||||
|
||||
if (isset($payload['nbf']) && is_numeric($payload['nbf']) && (int) $payload['nbf'] > time() + $this->notBeforeLeewaySeconds) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.');
|
||||
}
|
||||
|
||||
$issuer = $payload['iss'] ?? '';
|
||||
if (!\is_string($issuer) || !$this->isTrustedGraphIssuer($issuer)) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.');
|
||||
}
|
||||
|
||||
$scopes = $this->extractScopes($payload);
|
||||
|
||||
$attributes = [
|
||||
'oauth.claims' => $payload,
|
||||
'oauth.scopes' => $scopes,
|
||||
'oauth.graph_token' => true,
|
||||
];
|
||||
|
||||
if (isset($payload['sub'])) {
|
||||
$attributes['oauth.subject'] = $payload['sub'];
|
||||
}
|
||||
|
||||
if (isset($payload['oid'])) {
|
||||
$attributes['oauth.object_id'] = $payload['oid'];
|
||||
}
|
||||
|
||||
if (isset($payload['name'])) {
|
||||
$attributes['oauth.name'] = $payload['name'];
|
||||
}
|
||||
|
||||
return AuthorizationResult::allow($attributes);
|
||||
}
|
||||
|
||||
private function isTrustedGraphIssuer(string $issuer): bool
|
||||
{
|
||||
$host = parse_url($issuer, \PHP_URL_HOST);
|
||||
|
||||
return \in_array($host, $this->trustedGraphIssuers, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractScopes(array $claims): array
|
||||
{
|
||||
if (!isset($claims[$this->scopeClaim])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scopeValue = $claims[$this->scopeClaim];
|
||||
|
||||
if (\is_array($scopeValue)) {
|
||||
return array_values(array_filter($scopeValue, 'is_string'));
|
||||
}
|
||||
|
||||
if (\is_string($scopeValue)) {
|
||||
return array_values(array_filter(explode(' ', $scopeValue)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodePartToArray(string $part): ?array
|
||||
{
|
||||
$decoded = base64_decode(strtr($part, '-_', '+/'));
|
||||
if (false === $decoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($decoded, true);
|
||||
|
||||
return \is_array($data) ? $data : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?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\OAuthMicrosoft;
|
||||
|
||||
use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryMetadataPolicyInterface;
|
||||
|
||||
/**
|
||||
* Lenient metadata policy for Microsoft discovery quirks.
|
||||
*
|
||||
* `code_challenge_methods_supported` is optional in this policy.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class MicrosoftOidcMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface
|
||||
{
|
||||
public function isValid(mixed $metadata): bool
|
||||
{
|
||||
return \is_array($metadata)
|
||||
&& isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri'])
|
||||
&& \is_string($metadata['authorization_endpoint'])
|
||||
&& '' !== trim($metadata['authorization_endpoint'])
|
||||
&& \is_string($metadata['token_endpoint'])
|
||||
&& '' !== trim($metadata['token_endpoint'])
|
||||
&& \is_string($metadata['jwks_uri'])
|
||||
&& '' !== trim($metadata['jwks_uri']);
|
||||
}
|
||||
}
|
||||
222
examples/server/oauth-microsoft/README.md
Normal file
222
examples/server/oauth-microsoft/README.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# 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
|
||||
- Microsoft-specific validator/discovery overrides for Entra quirks
|
||||
- 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
|
||||
- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens
|
||||
- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy
|
||||
- `McpElements.php` - MCP tools including Graph API integration
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| 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 `MicrosoftJwtTokenValidator`.
|
||||
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`
|
||||
|
||||
### `code_challenge_methods_supported` missing in discovery metadata
|
||||
|
||||
This example configures `OidcDiscovery` with `MicrosoftOidcMetadataPolicy`, so this
|
||||
field can be missing or malformed and will not fail discovery.
|
||||
|
||||
### Graph API errors
|
||||
|
||||
1. Ensure `AZURE_CLIENT_SECRET` is set
|
||||
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
|
||||
5. **Important:** `MicrosoftJwtTokenValidator` in this example accepts `nonce` Graph-style tokens via claim checks only (`iss`/`exp`/`nbf`) without signature verification. Treat this as demo-only behavior and replace it with full signature validation for production.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
43
examples/server/oauth-microsoft/docker-compose.yml
Normal file
43
examples/server/oauth-microsoft/docker-compose.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
services:
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mcp-php-microsoft
|
||||
volumes:
|
||||
- ../../../:/app
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AZURE_TENANT_ID: ${AZURE_TENANT_ID:-}
|
||||
AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-}
|
||||
AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-}
|
||||
command: >
|
||||
sh -c "mkdir -p /app/examples/server/oauth-microsoft/sessions;
|
||||
chmod -R 0777 /app/examples/server/oauth-microsoft/sessions;
|
||||
touch /app/examples/server/oauth-microsoft/dev.log;
|
||||
chmod 0666 /app/examples/server/oauth-microsoft/dev.log;
|
||||
touch /app/examples/server/dev.log;
|
||||
chmod 0666 /app/examples/server/dev.log;
|
||||
composer install --no-interaction --quiet 2>/dev/null || true;
|
||||
php-fpm"
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: mcp-nginx-microsoft
|
||||
ports:
|
||||
- "${MCP_HTTP_PORT:-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
|
||||
18
examples/server/oauth-microsoft/env.example
Normal file
18
examples/server/oauth-microsoft/env.example
Normal file
|
|
@ -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
|
||||
25
examples/server/oauth-microsoft/nginx/default.conf
Normal file
25
examples/server/oauth-microsoft/nginx/default.conf
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
91
examples/server/oauth-microsoft/server.php
Normal file
91
examples/server/oauth-microsoft/server.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?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\Example\Server\OAuthMicrosoft\MicrosoftJwtTokenValidator;
|
||||
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftOidcMetadataPolicy;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Session\FileSessionStore;
|
||||
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\OAuthProxyMiddleware;
|
||||
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;
|
||||
|
||||
$tenantId = getenv('AZURE_TENANT_ID') ?: throw new RuntimeException('AZURE_TENANT_ID environment variable is required');
|
||||
$clientId = getenv('AZURE_CLIENT_ID') ?: throw new RuntimeException('AZURE_CLIENT_ID environment variable is required');
|
||||
|
||||
$issuerV2 = "https://login.microsoftonline.com/{$tenantId}/v2.0";
|
||||
$issuerV1 = "https://sts.windows.net/{$tenantId}/";
|
||||
$localBaseUrl = 'http://localhost:8000';
|
||||
|
||||
$discovery = new OidcDiscovery(
|
||||
metadataPolicy: new MicrosoftOidcMetadataPolicy(),
|
||||
);
|
||||
|
||||
$jwtTokenValidator = new JwtTokenValidator(
|
||||
issuer: [$issuerV2, $issuerV1],
|
||||
audience: $clientId,
|
||||
jwksProvider: new JwksProvider($discovery),
|
||||
jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
|
||||
$validator = new MicrosoftJwtTokenValidator($jwtTokenValidator);
|
||||
|
||||
$protectedResourceMetadata = new ProtectedResourceMetadata(
|
||||
authorizationServers: [$localBaseUrl],
|
||||
scopesSupported: ['openid', 'profile', 'email'],
|
||||
resourceName: 'OAuth Microsoft Example MCP Server',
|
||||
resourceDocumentation: $localBaseUrl,
|
||||
);
|
||||
|
||||
$metadataMiddleware = new ProtectedResourceMetadataMiddleware($protectedResourceMetadata);
|
||||
|
||||
$clientSecret = getenv('AZURE_CLIENT_SECRET') ?: null;
|
||||
|
||||
$oauthProxyMiddleware = new OAuthProxyMiddleware(
|
||||
upstreamIssuer: $issuerV2,
|
||||
localBaseUrl: $localBaseUrl,
|
||||
discovery: $discovery,
|
||||
clientSecret: $clientSecret,
|
||||
);
|
||||
|
||||
$authMiddleware = new AuthorizationMiddleware(
|
||||
$validator,
|
||||
$protectedResourceMetadata,
|
||||
);
|
||||
|
||||
$server = Server::builder()
|
||||
->setServerInfo('OAuth Microsoft Example', '1.0.0')
|
||||
->setLogger(logger())
|
||||
->setSession(new FileSessionStore(__DIR__.'/sessions'))
|
||||
->setDiscovery(__DIR__)
|
||||
->build();
|
||||
|
||||
$transport = new StreamableHttpTransport(
|
||||
(new Psr17Factory())->createServerRequestFromGlobals(),
|
||||
logger: logger(),
|
||||
middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()],
|
||||
);
|
||||
|
||||
$response = $server->run($transport);
|
||||
|
||||
(new SapiEmitter())->emit($response);
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* 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\OAuthMicrosoft\Tests\Unit;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftJwtTokenValidator;
|
||||
use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
|
||||
use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Tests MicrosoftJwtTokenValidator for Graph and non-Graph token flows.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
class MicrosoftJwtTokenValidatorTest extends TestCase
|
||||
{
|
||||
#[TestDox('non-Graph Microsoft token is validated via JWKS')]
|
||||
public function testNonGraphTokenUsesStandardJwtValidation(): void
|
||||
{
|
||||
$factory = new Psr17Factory();
|
||||
[$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid');
|
||||
|
||||
$jwksUri = 'https://login.microsoftonline.com/common/discovery/v2.0/keys';
|
||||
$httpClient = $this->createHttpClientMock([
|
||||
$factory->createResponse(200)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))),
|
||||
]);
|
||||
|
||||
$jwtTokenValidator = new JwtTokenValidator(
|
||||
issuer: 'https://login.microsoftonline.com/tenant-id/v2.0',
|
||||
audience: 'mcp-api',
|
||||
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory),
|
||||
jwksUri: $jwksUri,
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
$validator = new MicrosoftJwtTokenValidator(jwtTokenValidator: $jwtTokenValidator);
|
||||
|
||||
$token = JWT::encode(
|
||||
[
|
||||
'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0',
|
||||
'aud' => 'mcp-api',
|
||||
'sub' => 'user-123',
|
||||
'scp' => 'files.read files.write',
|
||||
'iat' => time() - 10,
|
||||
'exp' => time() + 600,
|
||||
],
|
||||
$privateKeyPem,
|
||||
'RS256',
|
||||
keyId: 'test-kid',
|
||||
);
|
||||
|
||||
$result = $validator->validate($token);
|
||||
|
||||
$this->assertTrue($result->isAllowed());
|
||||
$this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']);
|
||||
$this->assertSame('user-123', $result->getAttributes()['oauth.subject']);
|
||||
$this->assertArrayNotHasKey('oauth.graph_token', $result->getAttributes());
|
||||
}
|
||||
|
||||
#[TestDox('Graph token with nonce header is validated by claims only')]
|
||||
public function testGraphTokenWithNonceHeaderIsAllowed(): void
|
||||
{
|
||||
$factory = new Psr17Factory();
|
||||
$token = $this->buildGraphToken([
|
||||
'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0',
|
||||
'aud' => 'mcp-api',
|
||||
'sub' => 'user-graph',
|
||||
'scp' => 'files.read files.write',
|
||||
'iat' => time() - 10,
|
||||
'exp' => time() + 600,
|
||||
]);
|
||||
|
||||
$jwtTokenValidator = new JwtTokenValidator(
|
||||
issuer: ['https://auth.example.com'],
|
||||
audience: ['mcp-api'],
|
||||
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
|
||||
httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
|
||||
requestFactory: $factory
|
||||
),
|
||||
jwksUri: 'https://unused.example.com/jwks',
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
$validator = new MicrosoftJwtTokenValidator(
|
||||
jwtTokenValidator: $jwtTokenValidator,
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
|
||||
$result = $validator->validate($token);
|
||||
|
||||
$this->assertTrue($result->isAllowed());
|
||||
$this->assertTrue($result->getAttributes()['oauth.graph_token']);
|
||||
$this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']);
|
||||
$this->assertSame('user-graph', $result->getAttributes()['oauth.subject']);
|
||||
}
|
||||
|
||||
#[TestDox('Graph token with invalid payload is unauthorized')]
|
||||
public function testGraphTokenInvalidPayloadIsUnauthorized(): void
|
||||
{
|
||||
$factory = new Psr17Factory();
|
||||
$header = $this->b64urlEncode(json_encode([
|
||||
'alg' => 'none',
|
||||
'typ' => 'JWT',
|
||||
'nonce' => 'abc',
|
||||
], \JSON_THROW_ON_ERROR));
|
||||
$token = $header.'..';
|
||||
|
||||
$jwtTokenValidator = new JwtTokenValidator(
|
||||
issuer: ['https://auth.example.com'],
|
||||
audience: ['mcp-api'],
|
||||
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
|
||||
httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
|
||||
requestFactory: $factory
|
||||
),
|
||||
jwksUri: 'https://unused.example.com/jwks',
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
$validator = new MicrosoftJwtTokenValidator(
|
||||
jwtTokenValidator: $jwtTokenValidator,
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
|
||||
$result = $validator->validate($token);
|
||||
|
||||
$this->assertFalse($result->isAllowed());
|
||||
$this->assertSame(401, $result->getStatusCode());
|
||||
$this->assertSame('invalid_token', $result->getError());
|
||||
$this->assertSame('Invalid token payload.', $result->getErrorDescription());
|
||||
}
|
||||
|
||||
#[TestDox('Graph token with invalid issuer is unauthorized')]
|
||||
public function testGraphTokenInvalidIssuerIsUnauthorized(): void
|
||||
{
|
||||
$factory = new Psr17Factory();
|
||||
$token = $this->buildGraphToken([
|
||||
'iss' => 'https://evil.example.com',
|
||||
'aud' => 'mcp-api',
|
||||
'sub' => 'user-graph',
|
||||
'scp' => 'files.read',
|
||||
'iat' => time() - 10,
|
||||
'exp' => time() + 600,
|
||||
]);
|
||||
|
||||
$jwtTokenValidator = new JwtTokenValidator(
|
||||
issuer: ['https://auth.example.com'],
|
||||
audience: ['mcp-api'],
|
||||
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
|
||||
httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0),
|
||||
requestFactory: $factory
|
||||
),
|
||||
jwksUri: 'https://unused.example.com/jwks',
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
$validator = new MicrosoftJwtTokenValidator(
|
||||
jwtTokenValidator: $jwtTokenValidator,
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
|
||||
$result = $validator->validate($token);
|
||||
|
||||
$this->assertFalse($result->isAllowed());
|
||||
$this->assertSame(401, $result->getStatusCode());
|
||||
$this->assertSame('invalid_token', $result->getError());
|
||||
$this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription());
|
||||
}
|
||||
|
||||
#[TestDox('scope checks are delegated to base JwtTokenValidator')]
|
||||
public function testRequireScopesDelegatesToJwtTokenValidator(): void
|
||||
{
|
||||
$factory = new Psr17Factory();
|
||||
$jwtTokenValidator = new JwtTokenValidator(
|
||||
issuer: ['https://auth.example.com'],
|
||||
audience: ['mcp-api'],
|
||||
jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(),
|
||||
httpClient: $this->createHttpClientMock([], 0),
|
||||
requestFactory: $factory,
|
||||
),
|
||||
jwksUri: 'https://unused.example.com/jwks',
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
$validator = new MicrosoftJwtTokenValidator(
|
||||
jwtTokenValidator: $jwtTokenValidator,
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
|
||||
$result = AuthorizationResult::allow([
|
||||
'oauth.scopes' => ['files.read'],
|
||||
]);
|
||||
$scoped = $validator->requireScopes($result, ['files.read', 'files.write']);
|
||||
|
||||
$this->assertFalse($scoped->isAllowed());
|
||||
$this->assertSame(403, $scoped->getStatusCode());
|
||||
$this->assertSame('insufficient_scope', $scoped->getError());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function buildGraphToken(array $claims): string
|
||||
{
|
||||
$header = $this->b64urlEncode(json_encode([
|
||||
'alg' => 'none',
|
||||
'typ' => 'JWT',
|
||||
'nonce' => 'abc',
|
||||
], \JSON_THROW_ON_ERROR));
|
||||
|
||||
$payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR));
|
||||
|
||||
return $header.'.'.$payload.'.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: array<string, mixed>}
|
||||
*/
|
||||
private function generateRsaKeypairAsJwk(string $kid): array
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_type' => \OPENSSL_KEYTYPE_RSA,
|
||||
'private_key_bits' => 2048,
|
||||
]);
|
||||
|
||||
if (false === $key) {
|
||||
$this->fail('Failed to generate RSA keypair via OpenSSL.');
|
||||
}
|
||||
|
||||
$privateKeyPem = '';
|
||||
if (!openssl_pkey_export($key, $privateKeyPem)) {
|
||||
$this->fail('Failed to export RSA private key.');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($key);
|
||||
if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) {
|
||||
$this->fail('Failed to read RSA key details.');
|
||||
}
|
||||
|
||||
$n = $this->b64urlEncode($details['rsa']['n']);
|
||||
$e = $this->b64urlEncode($details['rsa']['e']);
|
||||
|
||||
$publicJwk = [
|
||||
'kty' => 'RSA',
|
||||
'kid' => $kid,
|
||||
'use' => 'sig',
|
||||
'alg' => 'RS256',
|
||||
'n' => $n,
|
||||
'e' => $e,
|
||||
];
|
||||
|
||||
return [$privateKeyPem, $publicJwk];
|
||||
}
|
||||
|
||||
private function b64urlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function createDiscoveryStub(): OidcDiscoveryInterface
|
||||
{
|
||||
return $this->createStub(OidcDiscoveryInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ResponseInterface> $responses
|
||||
*/
|
||||
private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface
|
||||
{
|
||||
$expectedCalls ??= \count($responses);
|
||||
|
||||
$client = $this->createMock(ClientInterface::class);
|
||||
$client
|
||||
->expects($this->exactly($expectedCalls))
|
||||
->method('sendRequest')
|
||||
->with($this->isInstanceOf(RequestInterface::class))
|
||||
->willReturnCallback(static function () use (&$responses): ResponseInterface {
|
||||
if ([] === $responses) {
|
||||
throw new \RuntimeException('No more mocked responses available.');
|
||||
}
|
||||
|
||||
return array_shift($responses);
|
||||
});
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue