[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
1 changed files with 6038 additions and 0 deletions
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
|
||||
Loading…
Reference in a new issue