diff --git a/composer.json b/composer.json index 411fe45a..7174ae6a 100644 --- a/composer.json +++ b/composer.json @@ -62,6 +62,8 @@ "Mcp\\Example\\DiscoveryUserProfile\\": "examples/discovery-userprofile/", "Mcp\\Example\\EnvVariables\\": "examples/env-variables/", "Mcp\\Example\\ExplicitRegistration\\": "examples/explicit-registration/", + "Mcp\\Example\\MicrosoftOAuth2\\": "examples/microsoft-oauth2/", + "Mcp\\Example\\OAuth2Generic\\": "examples/oauth2-generic/", "Mcp\\Example\\SchemaShowcase\\": "examples/schema-showcase/", "Mcp\\Tests\\": "tests/" } diff --git a/docs/oauth2-authentication.md b/docs/oauth2-authentication.md new file mode 100644 index 00000000..1a1e3c04 --- /dev/null +++ b/docs/oauth2-authentication.md @@ -0,0 +1,342 @@ +# OAuth 2.0 Authentication + +This document describes how to implement OAuth 2.0 authentication for MCP servers using the PHP SDK, following the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). + +## Overview + +The MCP OAuth 2.0 implementation follows these standards: + +- **OAuth 2.1** (IETF DRAFT) - Core authentication framework +- **RFC 9728** - Protected Resource Metadata +- **RFC 8414** - Authorization Server Metadata Discovery +- **RFC 7591** - Dynamic Client Registration +- **RFC 7592** - Client Registration Management +- **RFC 7662** - Token Introspection +- **RFC 6750** - Bearer Token Usage +- **RFC 8707** - Resource Indicators + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ MCP Server │────▶│ Auth Server │ +│ │ │ (Resource) │ │ (Keycloak, │ +│ │ │ │ │ Azure AD...) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ │ │ + │ 1. Discovery │ │ + │──────────────────────▶│ │ + │ Protected Resource │ │ + │ Metadata (401) │ │ + │◀──────────────────────│ │ + │ │ │ + │ 2. Get Token │ │ + │──────────────────────────────────────────────▶│ + │ │ │ + │ 3. Token Response │ │ + │◀──────────────────────────────────────────────│ + │ │ │ + │ 4. MCP Request │ │ + │ (Bearer Token) │ │ + │──────────────────────▶│ │ + │ │ 5. Validate JWT │ + │ │ (via JWKS) │ + │ │ │ + │ 6. MCP Response │ │ + │◀──────────────────────│ │ +``` + +## Quick Start + +### 1. Configure the Token Authenticator + +```php +use Mcp\Server\Auth\JwtTokenAuthenticator; + +$tokenAuthenticator = new JwtTokenAuthenticator( + jwksUri: 'https://your-auth-server/.well-known/jwks.json', + issuer: 'https://your-auth-server', + audience: 'your-api-identifier', // MCP server canonical URI + algorithms: ['RS256'], +); +``` + +### 2. Define Protected Resource Metadata + +```php +use Mcp\Server\Auth\ProtectedResourceMetadata; + +$resourceMetadata = new ProtectedResourceMetadata( + resource: 'https://mcp.example.com', + authorizationServers: ['https://your-auth-server'], + scopesSupported: ['mcp:read', 'mcp:write'], +); +``` + +### 3. Create OAuth2 Configuration + +```php +use Mcp\Server\Auth\OAuth2Configuration; + +$authConfig = new OAuth2Configuration( + tokenAuthenticator: $tokenAuthenticator, + resourceMetadata: $resourceMetadata, +); +``` + +### 4. Use OAuth2-Enabled Transport + +```php +use Mcp\Server\Transport\OAuth2HttpTransport; + +$transport = new OAuth2HttpTransport( + request: $psrServerRequest, + authConfig: $authConfig, + logger: $logger, +); + +$server->run($transport); +``` + +## Components + +### JwtTokenAuthenticator + +Validates JWT access tokens using public keys from a JWKS endpoint. + +**Constructor Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `jwksUri` | string | URL to fetch JSON Web Key Set | +| `issuer` | string | Expected token issuer (`iss` claim) | +| `audience` | string\|null | Expected audience (`aud` claim) | +| `algorithms` | string[] | Allowed signing algorithms | +| `leeway` | int | Clock skew tolerance in seconds | +| `jwksCacheTtl` | int | JWKS cache duration in seconds | + +**Supported Algorithms:** +- RS256, RS384, RS512 (RSA) +- ES256, ES384, ES512 (ECDSA) + +### ProtectedResourceMetadata + +Represents the OAuth 2.0 Protected Resource Metadata document (RFC 9728). + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `resource` | string | Canonical URI of the MCP server | +| `authorizationServers` | string[] | List of authorization server issuers | +| `scopesSupported` | string[]\|null | Supported OAuth scopes | +| `bearerMethodsSupported` | string[]\|null | Token delivery methods | +| `resourceName` | string\|null | Human-readable name | + +### OAuth2Configuration + +Combines all OAuth2 settings for the transport. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `tokenAuthenticator` | TokenAuthenticatorInterface | Token validator | +| `resourceMetadata` | ProtectedResourceMetadata | RFC 9728 metadata | +| `publicPaths` | string[] | Paths that skip authentication | + +### OAuth2HttpTransport + +HTTP transport with built-in OAuth2 authentication. + +**Features:** +- Automatic Protected Resource Metadata endpoint (`/.well-known/oauth-protected-resource`) +- Bearer token extraction from Authorization header +- WWW-Authenticate challenges for 401/403 responses +- Scope-based access control + +## Provider Examples + +### Microsoft Entra ID (Azure AD) + +```php +$tenantId = 'your-tenant-id'; +$clientId = 'your-client-id'; + +$tokenAuthenticator = new JwtTokenAuthenticator( + jwksUri: "https://login.microsoftonline.com/{$tenantId}/discovery/v2.0/keys", + issuer: "https://login.microsoftonline.com/{$tenantId}/v2.0", + audience: $clientId, +); +``` + +### Auth0 + +```php +$domain = 'your-domain.auth0.com'; + +$tokenAuthenticator = new JwtTokenAuthenticator( + jwksUri: "https://{$domain}/.well-known/jwks.json", + issuer: "https://{$domain}/", + audience: 'your-api-identifier', +); +``` + +### Keycloak + +```php +$realm = 'your-realm'; +$keycloakUrl = 'https://keycloak.example.com'; + +$tokenAuthenticator = new JwtTokenAuthenticator( + jwksUri: "{$keycloakUrl}/realms/{$realm}/protocol/openid-connect/certs", + issuer: "{$keycloakUrl}/realms/{$realm}", + audience: 'your-client-id', +); +``` + +### Okta + +```php +$domain = 'your-domain.okta.com'; +$authServerId = 'default'; // or custom auth server ID + +$tokenAuthenticator = new JwtTokenAuthenticator( + jwksUri: "https://{$domain}/oauth2/{$authServerId}/v1/keys", + issuer: "https://{$domain}/oauth2/{$authServerId}", + audience: 'api://your-api', +); +``` + +## Error Handling + +### 401 Unauthorized + +Returned when: +- No Authorization header present +- Invalid token format +- Token validation fails (expired, invalid signature, wrong issuer/audience) + +Response includes `WWW-Authenticate` header with: +- `resource_metadata` - URL to Protected Resource Metadata +- `scope` - Required scopes (if configured) + +### 403 Forbidden + +Returned when the token is valid but lacks required scopes. + +Response includes `WWW-Authenticate` header with: +- `error="insufficient_scope"` +- `scope` - Required scopes for the operation + +## Implementing Custom Token Validation + +Create a custom authenticator by implementing `TokenAuthenticatorInterface`: + +```php +use Mcp\Server\Auth\TokenAuthenticatorInterface; +use Mcp\Server\Auth\AuthenticationResult; + +class CustomTokenAuthenticator implements TokenAuthenticatorInterface +{ + public function authenticate(string $token, ?string $resource = null): AuthenticationResult + { + // Your validation logic here + // Could use token introspection, database lookup, etc. + + if ($valid) { + return AuthenticationResult::authenticated([ + 'sub' => 'user-id', + 'scope' => 'read write', + // ... other claims + ]); + } + + return AuthenticationResult::unauthenticated( + 'invalid_token', + 'Token validation failed' + ); + } +} +``` + +## Testing with Docker + +The SDK includes a Docker setup with Keycloak for local testing: + +```bash +cd docker +docker-compose up +``` + +This starts: +- MCP Server at http://localhost:8080 +- Keycloak at http://localhost:8180 (admin/admin) +- MCP Inspector at http://localhost:6274 + +See `docker/README.md` for detailed instructions. + +### Confidential Clients + +For server-side applications: + +```php +$clientMetadata = ClientRegistration::forConfidentialClient( + redirectUris: ['https://myapp.example.com/callback'], + clientName: 'My Server App', + scope: 'mcp:read mcp:write mcp:admin', + tokenEndpointAuthMethod: 'client_secret_basic', // or 'client_secret_post', 'private_key_jwt' +); +``` + +## Token Introspection + +For opaque tokens, use RFC 7662 Token Introspection: + +```php +use Mcp\Server\Auth\IntrospectionTokenAuthenticator; + +$tokenAuthenticator = new IntrospectionTokenAuthenticator( + introspectionEndpoint: 'https://auth.example.com/oauth/introspect', + clientId: 'mcp-server', + clientSecret: 'server-secret', + expectedAudience: 'https://mcp.example.com', + logger: $logger, +); +``` + +## Complete Classes Reference + +| Class | RFC | Description | +|-------|-----|-------------| +| `JwtTokenAuthenticator` | - | JWKS-based JWT validation | +| `IntrospectionTokenAuthenticator` | 7662 | Token introspection | +| `ProtectedResourceMetadata` | 9728 | Protected resource metadata | +| `OAuth2Configuration` | - | OAuth2 configuration container | +| `OAuth2HttpTransport` | - | HTTP transport with OAuth2 | +| `WwwAuthenticateChallenge` | 6750 | WWW-Authenticate header builder | +| `AuthorizationServerMetadata` | 8414 | Auth server metadata model | +| `DynamicClientRegistration` | 7591 | Dynamic client registration | +| `ClientRegistration` | 7591 | Client registration metadata | +| `ClientRegistrationResponse` | 7591 | Registration response model | + +## Security Considerations + +1. **Always use HTTPS** in production +2. **Validate audience** to prevent token misuse +3. **Use short-lived tokens** with refresh tokens +4. **Implement scope-based access control** for sensitive operations +5. **Cache JWKS appropriately** but allow for key rotation +6. **Validate PKCE support** before proceeding with authorization +7. **Store registration tokens securely** if using dynamic client registration + +## Further Reading + +- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [RFC 9728 - Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +- [RFC 8414 - Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) +- [RFC 7591 - Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) +- [RFC 7662 - Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662) +- [RFC 6750 - Bearer Token Usage](https://datatracker.ietf.org/doc/html/rfc6750) +- [OAuth 2.1 Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) + diff --git a/examples/README.md b/examples/README.md index 27874d71..e2eac4f8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,33 @@ Run with Inspector: npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php ``` +## OAuth 2.0 Examples + +The SDK supports OAuth 2.0 authentication following the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). + +### Microsoft Entra ID (Azure AD) + +```bash +export MICROSOFT_TENANT_ID=your-tenant-id +export MICROSOFT_CLIENT_ID=your-client-id +export MCP_SERVER_URL=http://localhost:8080 + +php -S localhost:8080 examples/microsoft-oauth2/server.php +``` + +### Generic OAuth 2.0 (Auth0, Okta, Keycloak, etc.) + +```bash +export OAUTH_JWKS_URI=https://your-provider/.well-known/jwks.json +export OAUTH_ISSUER=https://your-provider +export OAUTH_AUDIENCE=your-api-identifier +export MCP_SERVER_URL=http://localhost:8080 + +php -S localhost:8080 examples/oauth2-generic/server.php +``` + +See [docs/oauth2-authentication.md](../docs/oauth2-authentication.md) for full documentation. + ## Debugging You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by diff --git a/examples/microsoft-oauth2/McpElements.php b/examples/microsoft-oauth2/McpElements.php new file mode 100644 index 00000000..b011742c --- /dev/null +++ b/examples/microsoft-oauth2/McpElements.php @@ -0,0 +1,105 @@ + 'MCP Server with Microsoft Authentication', + 'version' => '1.0.0', + 'auth_provider' => 'Microsoft Entra ID', + 'timestamp' => date('c'), + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)), + ]; + } + + /** + * Echo back a message - demonstrates a simple protected tool. + * + * @return TextContent[] + */ + #[McpTool( + name: 'echo', + description: 'Echo back a message. Demonstrates a protected tool that requires authentication.', + )] + public function echo(string $message): array + { + return [ + new TextContent("Echo: {$message}"), + ]; + } + + /** + * Get a protected resource. + * + * @return TextContent[] + */ + #[McpResource( + uri: 'mcp://microsoft-auth/protected-data', + name: 'Protected Data', + description: 'A protected resource that requires authentication to access.', + )] + public function getProtectedData(): array + { + return [ + new TextContent(json_encode([ + 'data' => 'This is protected data', + 'accessed_at' => date('c'), + 'message' => 'You successfully accessed protected data using Microsoft authentication!', + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)), + ]; + } + + /** + * A protected prompt. + * + * @return TextContent[] + */ + #[McpPrompt( + name: 'secure_assistant', + description: 'A prompt for a secure assistant that requires authentication.', + )] + public function secureAssistantPrompt(?string $context = null): array + { + $promptText = "You are a secure assistant operating in an authenticated MCP server environment.\n\n"; + $promptText .= "The user has been authenticated via Microsoft Entra ID.\n"; + + if ($context) { + $promptText .= "\nAdditional context: {$context}"; + } + + return [ + new TextContent($promptText), + ]; + } +} + diff --git a/examples/microsoft-oauth2/server.php b/examples/microsoft-oauth2/server.php new file mode 100644 index 00000000..4b5f2976 --- /dev/null +++ b/examples/microsoft-oauth2/server.php @@ -0,0 +1,122 @@ +#!/usr/bin/env php +info('Starting MCP Server with Microsoft Entra ID authentication...', [ + 'tenant_id' => $tenantId, + 'client_id' => $clientId, + 'server_url' => $serverUrl, +]); + +// Microsoft Entra ID JWKS endpoint +$jwksUri = "https://login.microsoftonline.com/{$tenantId}/discovery/v2.0/keys"; +$issuer = "https://login.microsoftonline.com/{$tenantId}/v2.0"; + +// Create JWT authenticator for Microsoft tokens +$tokenAuthenticator = new JwtTokenAuthenticator( + jwksUri: $jwksUri, + issuer: $issuer, + audience: $clientId, // Or use api://{clientId} for custom API scope + algorithms: ['RS256'], + leeway: 60, + jwksCacheTtl: 3600, + logger: logger(), +); + +// Define protected resource metadata (RFC 9728) +// This tells MCP clients how to authenticate +$resourceMetadata = new ProtectedResourceMetadata( + resource: $serverUrl, + authorizationServers: [$issuer], + scopesSupported: [ + 'api://02d751ab-963d-4ea1-bfad-79c2ed220269/mcp.read', + 'api://02d751ab-963d-4ea1-bfad-79c2ed220269/mcp.write', + 'api://02d751ab-963d-4ea1-bfad-79c2ed220269/mcp.admin', + 'http://localhost:8080/mcp.read', + 'http://localhost:8080/mcp.write', + 'http://localhost:8080/mcp.admin', + ], + bearerMethodsSupported: ['header'], + resourceName: 'MCP Server with Microsoft Auth 3', + resourceDocumentation: 'https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app', +); + +// OAuth2 configuration +$authConfig = new OAuth2Configuration( + tokenAuthenticator: $tokenAuthenticator, + resourceMetadata: $resourceMetadata, + publicPaths: [ + '/health', // Health check endpoint + ], +); + +// Build the server +$server = Server::builder() + ->setServerInfo('MicrosoftAuthMcpServer', '1.0.0', 'MCP Server with Microsoft Entra ID Authentication') + ->setInstructions('This MCP server requires Microsoft Entra ID authentication. Obtain an access token from Azure AD and include it in the Authorization header.') + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) + ->setDiscovery(__DIR__) + ->build(); + +// Create OAuth2-enabled transport +if ('cli' === PHP_SAPI) { + // For CLI testing, skip OAuth2 (STDIO doesn't support it per MCP spec) + $transport = new \Mcp\Server\Transport\StdioTransport(logger: logger()); + logger()->info('Running in STDIO mode (no OAuth2)'); +} else { + $request = (new Psr17Factory())->createServerRequestFromGlobals(); + $transport = new OAuth2HttpTransport( + authConfig: $authConfig, + request: $request, + logger: logger(), + ); + logger()->info('Running in HTTP mode with OAuth2 authentication'); +} + +$result = $server->run($transport); + +logger()->info('Server stopped.', ['result' => is_int($result) ? $result : 'response']); + +shutdown($result); diff --git a/examples/oauth2-generic/McpElements.php b/examples/oauth2-generic/McpElements.php new file mode 100644 index 00000000..f0f77025 --- /dev/null +++ b/examples/oauth2-generic/McpElements.php @@ -0,0 +1,143 @@ + 'operational', + 'auth_type' => 'OAuth 2.0 Bearer Token', + 'server_time' => date('c'), + 'php_version' => PHP_VERSION, + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)), + ]; + } + + /** + * Calculate the sum of two numbers. + * + * @return TextContent[] + */ + #[McpTool( + name: 'calculate', + description: 'Perform a calculation. Demonstrates a protected tool.', + )] + public function calculate(float $a, float $b, string $operation = 'add'): array + { + $result = match ($operation) { + 'add' => $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => $b !== 0.0 ? $a / $b : throw new \InvalidArgumentException('Cannot divide by zero'), + default => throw new \InvalidArgumentException("Unknown operation: {$operation}"), + }; + + return [ + new TextContent(json_encode([ + 'a' => $a, + 'b' => $b, + 'operation' => $operation, + 'result' => $result, + ], JSON_THROW_ON_ERROR)), + ]; + } + + /** + * Get public configuration (no scope required). + * + * @return TextContent[] + */ + #[McpResource( + uri: 'mcp://oauth2/config', + name: 'Server Configuration', + description: 'Public server configuration.', + )] + public function getConfig(): array + { + return [ + new TextContent(json_encode([ + 'name' => 'Generic OAuth2 MCP Server', + 'version' => '1.0.0', + 'supported_operations' => ['add', 'subtract', 'multiply', 'divide'], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)), + ]; + } + + /** + * Get sensitive data (requires specific scope). + * + * @return TextContent[] + */ + #[McpResource( + uri: 'mcp://oauth2/sensitive-data', + name: 'Sensitive Data', + description: 'Sensitive data that requires elevated permissions.', + )] + public function getSensitiveData(): array + { + return [ + new TextContent(json_encode([ + 'type' => 'sensitive', + 'data' => 'This data is only accessible with proper scopes', + 'accessed_at' => date('c'), + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)), + ]; + } + + /** + * Default assistant prompt. + * + * @return TextContent[] + */ + #[McpPrompt( + name: 'oauth2_assistant', + description: 'A prompt for an OAuth2-authenticated assistant.', + )] + public function assistantPrompt(?string $task = null): array + { + $prompt = "You are an assistant running in an OAuth 2.0 protected MCP server.\n"; + $prompt .= "The user has been authenticated and their access token has been validated.\n\n"; + + if ($task) { + $prompt .= "Your current task is: {$task}\n"; + } + + $prompt .= "\nAvailable tools:\n"; + $prompt .= "- get_status: Check server status\n"; + $prompt .= "- calculate: Perform mathematical calculations\n"; + + return [ + new TextContent($prompt), + ]; + } +} + diff --git a/examples/oauth2-generic/server.php b/examples/oauth2-generic/server.php new file mode 100644 index 00000000..dd7cd79a --- /dev/null +++ b/examples/oauth2-generic/server.php @@ -0,0 +1,117 @@ +#!/usr/bin/env php +info('Starting MCP Server with OAuth 2.0 authentication...', [ + 'jwks_uri' => $jwksUri, + 'issuer' => $issuer, + 'audience' => $audience, + 'server_url' => $serverUrl, +]); + +// Create JWT authenticator +$tokenAuthenticator = new JwtTokenAuthenticator( + jwksUri: $jwksUri, + issuer: $issuer, + audience: $audience, + algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384'], + leeway: 60, + jwksCacheTtl: 3600, + logger: logger(), +); + +// Define protected resource metadata (RFC 9728) +$resourceMetadata = new ProtectedResourceMetadata( + resource: $serverUrl, + authorizationServers: [$issuer], + scopesSupported: $scopesSupported, + bearerMethodsSupported: ['header'], + resourceName: 'Generic OAuth2 MCP Server', +); + +// OAuth2 configuration +$authConfig = new OAuth2Configuration( + tokenAuthenticator: $tokenAuthenticator, + resourceMetadata: $resourceMetadata, + validateAudience: $validateAudience, +); + +// Build the server +$server = Server::builder() + ->setServerInfo('GenericOAuth2McpServer', '1.0.0', 'MCP Server with Generic OAuth 2.0 Authentication') + ->setInstructions('This MCP server requires OAuth 2.0 authentication. Obtain an access token from the configured authorization server and include it in the Authorization header.') + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) + ->setDiscovery(__DIR__) + ->build(); + +// Create transport based on SAPI +if ('cli' === PHP_SAPI) { + $transport = new \Mcp\Server\Transport\StdioTransport(logger: logger()); + logger()->info('Running in STDIO mode (no OAuth2)'); +} else { + $request = (new Psr17Factory())->createServerRequestFromGlobals(); + $transport = new OAuth2HttpTransport( + authConfig: $authConfig, + request: $request, + logger: logger(), + ); + logger()->info('Running in HTTP mode with OAuth2 authentication'); +} + +$result = $server->run($transport); + +logger()->info('Server stopped.', ['result' => is_int($result) ? $result : 'response']); + +shutdown($result); + diff --git a/src/Server/Auth/AuthenticationResult.php b/src/Server/Auth/AuthenticationResult.php new file mode 100644 index 00000000..e83af99b --- /dev/null +++ b/src/Server/Auth/AuthenticationResult.php @@ -0,0 +1,115 @@ + + */ +final class AuthenticationResult +{ + /** + * @param array $claims Token claims (subject, scopes, etc.) + * @param array $context Additional context about the authentication + */ + private function __construct( + public readonly bool $authenticated, + public readonly array $claims = [], + public readonly ?string $error = null, + public readonly ?string $errorDescription = null, + public readonly array $context = [], + ) { + } + + /** + * Create a successful authentication result. + * + * @param array $claims Token claims (sub, scope, aud, etc.) + * @param array $context Additional context + */ + public static function authenticated(array $claims, array $context = []): self + { + return new self( + authenticated: true, + claims: $claims, + context: $context, + ); + } + + /** + * Create a failed authentication result. + */ + public static function unauthenticated(string $error, ?string $errorDescription = null): self + { + return new self( + authenticated: false, + error: $error, + errorDescription: $errorDescription, + ); + } + + /** + * Get the subject claim (typically user or client ID). + */ + public function getSubject(): ?string + { + return $this->claims['sub'] ?? null; + } + + /** + * Get the scopes from the token. + * + * @return string[] + */ + public function getScopes(): array + { + $scope = $this->claims['scope'] ?? ''; + if (is_string($scope)) { + return array_filter(explode(' ', $scope)); + } + if (is_array($scope)) { + return $scope; + } + + return []; + } + + /** + * Check if the token has a specific scope. + */ + public function hasScope(string $scope): bool + { + return in_array($scope, $this->getScopes(), true); + } + + /** + * Check if the token has all specified scopes. + * + * @param string[] $scopes + */ + public function hasAllScopes(array $scopes): bool + { + $tokenScopes = $this->getScopes(); + foreach ($scopes as $scope) { + if (!in_array($scope, $tokenScopes, true)) { + return false; + } + } + + return true; + } +} + diff --git a/src/Server/Auth/AuthorizationServerMetadata.php b/src/Server/Auth/AuthorizationServerMetadata.php new file mode 100644 index 00000000..315b2d1a --- /dev/null +++ b/src/Server/Auth/AuthorizationServerMetadata.php @@ -0,0 +1,144 @@ + + */ +final class AuthorizationServerMetadata +{ + /** + * @param string $issuer Authorization server's issuer identifier + * @param string $authorizationEndpoint URL of the authorization endpoint + * @param string|null $tokenEndpoint URL of the token endpoint + * @param string|null $jwksUri URL of the server's JWKS + * @param string|null $registrationEndpoint URL for dynamic client registration + * @param string[]|null $scopesSupported Supported scopes + * @param string[] $responseTypesSupported Supported response types + * @param string[]|null $responseModesSupported Supported response modes + * @param string[]|null $grantTypesSupported Supported grant types + * @param string[]|null $tokenEndpointAuthMethodsSupported Token endpoint auth methods + * @param string[]|null $codeChallengeMethodsSupported PKCE code challenge methods + * @param string|null $introspectionEndpoint Token introspection endpoint + * @param string|null $revocationEndpoint Token revocation endpoint + * @param string|null $userinfoEndpoint OIDC userinfo endpoint + * @param bool $clientIdMetadataDocumentSupported Whether client ID metadata documents are supported + * @param array $additionalFields Any additional metadata fields + */ + public function __construct( + public readonly string $issuer, + public readonly string $authorizationEndpoint, + public readonly ?string $tokenEndpoint = null, + public readonly ?string $jwksUri = null, + public readonly ?string $registrationEndpoint = null, + public readonly ?array $scopesSupported = null, + public readonly array $responseTypesSupported = ['code'], + public readonly ?array $responseModesSupported = null, + public readonly ?array $grantTypesSupported = null, + public readonly ?array $tokenEndpointAuthMethodsSupported = null, + public readonly ?array $codeChallengeMethodsSupported = null, + public readonly ?string $introspectionEndpoint = null, + public readonly ?string $revocationEndpoint = null, + public readonly ?string $userinfoEndpoint = null, + public readonly bool $clientIdMetadataDocumentSupported = false, + public readonly array $additionalFields = [], + ) { + } + + /** + * Check if PKCE is supported (required by MCP spec). + */ + public function supportsPkce(): bool + { + return null !== $this->codeChallengeMethodsSupported + && !empty($this->codeChallengeMethodsSupported); + } + + /** + * Check if S256 PKCE method is supported (required by MCP spec when technically capable). + */ + public function supportsS256(): bool + { + return null !== $this->codeChallengeMethodsSupported + && in_array('S256', $this->codeChallengeMethodsSupported, true); + } + + /** + * Check if dynamic client registration is supported. + */ + public function supportsDynamicRegistration(): bool + { + return null !== $this->registrationEndpoint; + } + + /** + * Check if Client ID Metadata Documents are supported. + */ + public function supportsClientIdMetadataDocument(): bool + { + return $this->clientIdMetadataDocumentSupported; + } + + /** + * Check if a specific grant type is supported. + */ + public function supportsGrantType(string $grantType): bool + { + // Default grant types per RFC 8414 + $supported = $this->grantTypesSupported ?? ['authorization_code', 'implicit']; + + return in_array($grantType, $supported, true); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $knownFields = [ + 'issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', + 'registration_endpoint', 'scopes_supported', 'response_types_supported', + 'response_modes_supported', 'grant_types_supported', + 'token_endpoint_auth_methods_supported', 'code_challenge_methods_supported', + 'introspection_endpoint', 'revocation_endpoint', 'userinfo_endpoint', + 'client_id_metadata_document_supported', + ]; + + $additionalFields = array_diff_key($data, array_flip($knownFields)); + + return new self( + issuer: $data['issuer'] ?? throw new \InvalidArgumentException('Missing issuer'), + authorizationEndpoint: $data['authorization_endpoint'] ?? throw new \InvalidArgumentException('Missing authorization_endpoint'), + tokenEndpoint: $data['token_endpoint'] ?? null, + jwksUri: $data['jwks_uri'] ?? null, + registrationEndpoint: $data['registration_endpoint'] ?? null, + scopesSupported: $data['scopes_supported'] ?? null, + responseTypesSupported: $data['response_types_supported'] ?? ['code'], + responseModesSupported: $data['response_modes_supported'] ?? null, + grantTypesSupported: $data['grant_types_supported'] ?? null, + tokenEndpointAuthMethodsSupported: $data['token_endpoint_auth_methods_supported'] ?? null, + codeChallengeMethodsSupported: $data['code_challenge_methods_supported'] ?? null, + introspectionEndpoint: $data['introspection_endpoint'] ?? null, + revocationEndpoint: $data['revocation_endpoint'] ?? null, + userinfoEndpoint: $data['userinfo_endpoint'] ?? null, + clientIdMetadataDocumentSupported: $data['client_id_metadata_document_supported'] ?? false, + additionalFields: $additionalFields, + ); + } +} + diff --git a/src/Server/Auth/ClientRegistration.php b/src/Server/Auth/ClientRegistration.php new file mode 100644 index 00000000..5efc892d --- /dev/null +++ b/src/Server/Auth/ClientRegistration.php @@ -0,0 +1,192 @@ + + */ +final class ClientRegistration implements \JsonSerializable +{ + /** + * @param string[] $redirectUris Array of redirect URIs + * @param string|null $clientName Human-readable client name + * @param string|null $clientUri URL of the client's home page + * @param string|null $logoUri URL of the client's logo + * @param string[] $grantTypes Array of grant types (default: authorization_code) + * @param string[] $responseTypes Array of response types (default: code) + * @param string|null $scope Space-delimited scope string + * @param string[] $contacts Array of contact emails + * @param string|null $tosUri URL of terms of service + * @param string|null $policyUri URL of privacy policy + * @param string|null $jwksUri URL of client's JWKS + * @param array|null $jwks Client's JWKS (inline) + * @param string|null $softwareId Unique identifier for the client software + * @param string|null $softwareVersion Version of the client software + * @param string $tokenEndpointAuthMethod Token endpoint auth method (none, client_secret_basic, client_secret_post, private_key_jwt) + */ + public function __construct( + public readonly array $redirectUris, + public readonly ?string $clientName = null, + public readonly ?string $clientUri = null, + public readonly ?string $logoUri = null, + public readonly array $grantTypes = ['authorization_code'], + public readonly array $responseTypes = ['code'], + public readonly ?string $scope = null, + public readonly array $contacts = [], + public readonly ?string $tosUri = null, + public readonly ?string $policyUri = null, + public readonly ?string $jwksUri = null, + public readonly ?array $jwks = null, + public readonly ?string $softwareId = null, + public readonly ?string $softwareVersion = null, + public readonly string $tokenEndpointAuthMethod = 'none', + ) { + if (empty($redirectUris)) { + throw new \InvalidArgumentException('At least one redirect URI is required'); + } + } + + /** + * Create a registration for a public MCP client (typical for MCP). + * + * @param string[] $redirectUris Redirect URIs (typically localhost for native apps) + */ + public static function forPublicClient( + array $redirectUris, + string $clientName, + ?string $clientUri = null, + ?string $scope = null, + ): self { + return new self( + redirectUris: $redirectUris, + clientName: $clientName, + clientUri: $clientUri, + grantTypes: ['authorization_code', 'refresh_token'], + responseTypes: ['code'], + scope: $scope, + tokenEndpointAuthMethod: 'none', + ); + } + + /** + * Create a registration for a confidential client. + * + * @param string[] $redirectUris Redirect URIs + */ + public static function forConfidentialClient( + array $redirectUris, + string $clientName, + ?string $clientUri = null, + ?string $scope = null, + string $tokenEndpointAuthMethod = 'client_secret_basic', + ): self { + return new self( + redirectUris: $redirectUris, + clientName: $clientName, + clientUri: $clientUri, + grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'], + responseTypes: ['code'], + scope: $scope, + tokenEndpointAuthMethod: $tokenEndpointAuthMethod, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'redirect_uris' => $this->redirectUris, + 'grant_types' => $this->grantTypes, + 'response_types' => $this->responseTypes, + 'token_endpoint_auth_method' => $this->tokenEndpointAuthMethod, + ]; + + if (null !== $this->clientName) { + $data['client_name'] = $this->clientName; + } + + if (null !== $this->clientUri) { + $data['client_uri'] = $this->clientUri; + } + + if (null !== $this->logoUri) { + $data['logo_uri'] = $this->logoUri; + } + + if (null !== $this->scope) { + $data['scope'] = $this->scope; + } + + if (!empty($this->contacts)) { + $data['contacts'] = $this->contacts; + } + + if (null !== $this->tosUri) { + $data['tos_uri'] = $this->tosUri; + } + + if (null !== $this->policyUri) { + $data['policy_uri'] = $this->policyUri; + } + + if (null !== $this->jwksUri) { + $data['jwks_uri'] = $this->jwksUri; + } + + if (null !== $this->jwks) { + $data['jwks'] = $this->jwks; + } + + if (null !== $this->softwareId) { + $data['software_id'] = $this->softwareId; + } + + if (null !== $this->softwareVersion) { + $data['software_version'] = $this->softwareVersion; + } + + return $data; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + redirectUris: $data['redirect_uris'] ?? [], + clientName: $data['client_name'] ?? null, + clientUri: $data['client_uri'] ?? null, + logoUri: $data['logo_uri'] ?? null, + grantTypes: $data['grant_types'] ?? ['authorization_code'], + responseTypes: $data['response_types'] ?? ['code'], + scope: $data['scope'] ?? null, + contacts: $data['contacts'] ?? [], + tosUri: $data['tos_uri'] ?? null, + policyUri: $data['policy_uri'] ?? null, + jwksUri: $data['jwks_uri'] ?? null, + jwks: $data['jwks'] ?? null, + softwareId: $data['software_id'] ?? null, + softwareVersion: $data['software_version'] ?? null, + tokenEndpointAuthMethod: $data['token_endpoint_auth_method'] ?? 'none', + ); + } +} + diff --git a/src/Server/Auth/ClientRegistrationResponse.php b/src/Server/Auth/ClientRegistrationResponse.php new file mode 100644 index 00000000..8402f82a --- /dev/null +++ b/src/Server/Auth/ClientRegistrationResponse.php @@ -0,0 +1,184 @@ + + */ +final class ClientRegistrationResponse +{ + /** + * @param string $clientId The unique client identifier + * @param string|null $clientSecret The client secret (for confidential clients) + * @param int|null $clientIdIssuedAt Timestamp when client_id was issued + * @param int|null $clientSecretExpiresAt Timestamp when client_secret expires (0 = never) + * @param string|null $registrationAccessToken Token for managing registration (RFC 7592) + * @param string|null $registrationClientUri URI for managing registration (RFC 7592) + * @param string[] $redirectUris Registered redirect URIs + * @param string|null $clientName Human-readable client name + * @param string|null $clientUri URL of the client's home page + * @param string|null $logoUri URL of the client's logo + * @param string[] $grantTypes Allowed grant types + * @param string[] $responseTypes Allowed response types + * @param string|null $scope Allowed scope + * @param string $tokenEndpointAuthMethod Token endpoint auth method + * @param array $additionalFields Any additional fields from the response + */ + public function __construct( + public readonly string $clientId, + public readonly ?string $clientSecret = null, + public readonly ?int $clientIdIssuedAt = null, + public readonly ?int $clientSecretExpiresAt = null, + public readonly ?string $registrationAccessToken = null, + public readonly ?string $registrationClientUri = null, + public readonly array $redirectUris = [], + public readonly ?string $clientName = null, + public readonly ?string $clientUri = null, + public readonly ?string $logoUri = null, + public readonly array $grantTypes = [], + public readonly array $responseTypes = [], + public readonly ?string $scope = null, + public readonly string $tokenEndpointAuthMethod = 'none', + public readonly array $additionalFields = [], + ) { + } + + /** + * Check if this is a public client (no secret). + */ + public function isPublicClient(): bool + { + return null === $this->clientSecret; + } + + /** + * Check if the client secret has expired. + */ + public function isSecretExpired(): bool + { + if (null === $this->clientSecretExpiresAt || $this->clientSecretExpiresAt === 0) { + return false; + } + + return time() > $this->clientSecretExpiresAt; + } + + /** + * Check if client registration management is supported (RFC 7592). + */ + public function supportsManagement(): bool + { + return null !== $this->registrationAccessToken && null !== $this->registrationClientUri; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $knownFields = [ + 'client_id', 'client_secret', 'client_id_issued_at', 'client_secret_expires_at', + 'registration_access_token', 'registration_client_uri', 'redirect_uris', + 'client_name', 'client_uri', 'logo_uri', 'grant_types', 'response_types', + 'scope', 'token_endpoint_auth_method', + ]; + + $additionalFields = array_diff_key($data, array_flip($knownFields)); + + return new self( + clientId: $data['client_id'] ?? throw new \InvalidArgumentException('Missing client_id'), + clientSecret: $data['client_secret'] ?? null, + clientIdIssuedAt: isset($data['client_id_issued_at']) ? (int) $data['client_id_issued_at'] : null, + clientSecretExpiresAt: isset($data['client_secret_expires_at']) ? (int) $data['client_secret_expires_at'] : null, + registrationAccessToken: $data['registration_access_token'] ?? null, + registrationClientUri: $data['registration_client_uri'] ?? null, + redirectUris: $data['redirect_uris'] ?? [], + clientName: $data['client_name'] ?? null, + clientUri: $data['client_uri'] ?? null, + logoUri: $data['logo_uri'] ?? null, + grantTypes: $data['grant_types'] ?? [], + responseTypes: $data['response_types'] ?? [], + scope: $data['scope'] ?? null, + tokenEndpointAuthMethod: $data['token_endpoint_auth_method'] ?? 'none', + additionalFields: $additionalFields, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + $data = [ + 'client_id' => $this->clientId, + ]; + + if (null !== $this->clientSecret) { + $data['client_secret'] = $this->clientSecret; + } + + if (null !== $this->clientIdIssuedAt) { + $data['client_id_issued_at'] = $this->clientIdIssuedAt; + } + + if (null !== $this->clientSecretExpiresAt) { + $data['client_secret_expires_at'] = $this->clientSecretExpiresAt; + } + + if (null !== $this->registrationAccessToken) { + $data['registration_access_token'] = $this->registrationAccessToken; + } + + if (null !== $this->registrationClientUri) { + $data['registration_client_uri'] = $this->registrationClientUri; + } + + if (!empty($this->redirectUris)) { + $data['redirect_uris'] = $this->redirectUris; + } + + if (null !== $this->clientName) { + $data['client_name'] = $this->clientName; + } + + if (null !== $this->clientUri) { + $data['client_uri'] = $this->clientUri; + } + + if (null !== $this->logoUri) { + $data['logo_uri'] = $this->logoUri; + } + + if (!empty($this->grantTypes)) { + $data['grant_types'] = $this->grantTypes; + } + + if (!empty($this->responseTypes)) { + $data['response_types'] = $this->responseTypes; + } + + if (null !== $this->scope) { + $data['scope'] = $this->scope; + } + + $data['token_endpoint_auth_method'] = $this->tokenEndpointAuthMethod; + + return array_merge($data, $this->additionalFields); + } +} + diff --git a/src/Server/Auth/DynamicClientRegistration.php b/src/Server/Auth/DynamicClientRegistration.php new file mode 100644 index 00000000..566bbc86 --- /dev/null +++ b/src/Server/Auth/DynamicClientRegistration.php @@ -0,0 +1,242 @@ + + */ +final class DynamicClientRegistration +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private StreamFactoryInterface $streamFactory; + + /** + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + */ + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + /** + * Register a new OAuth client dynamically. + * + * @param string $registrationEndpoint The authorization server's registration endpoint + * @param ClientRegistration $clientMetadata Client metadata to register + * @param string|null $initialAccessToken Initial access token (if required by server) + * + * @return ClientRegistrationResponse The registered client information + * + * @throws \RuntimeException If registration fails + */ + public function register( + string $registrationEndpoint, + ClientRegistration $clientMetadata, + ?string $initialAccessToken = null, + ): ClientRegistrationResponse { + try { + $body = json_encode($clientMetadata, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + $request = $this->requestFactory->createRequest('POST', $registrationEndpoint) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json') + ->withBody($this->streamFactory->createStream($body)); + + if (null !== $initialAccessToken) { + $request = $request->withHeader('Authorization', 'Bearer ' . $initialAccessToken); + } + + $response = $this->httpClient->sendRequest($request); + $responseBody = (string) $response->getBody(); + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 201 && $statusCode !== 200) { + $this->logger->error('Dynamic client registration failed', [ + 'endpoint' => $registrationEndpoint, + 'status' => $statusCode, + 'response' => $responseBody, + ]); + + $error = json_decode($responseBody, true); + throw new \RuntimeException(sprintf( + 'Client registration failed: %s - %s', + $error['error'] ?? 'unknown_error', + $error['error_description'] ?? 'No description' + )); + } + + $data = json_decode($responseBody, true); + if (!is_array($data)) { + throw new \RuntimeException('Invalid registration response'); + } + + return ClientRegistrationResponse::fromArray($data); + } catch (\JsonException $e) { + throw new \RuntimeException('Failed to encode/decode registration data: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Update an existing client registration (RFC 7592). + * + * @param string $configurationEndpoint The client's registration configuration endpoint + * @param string $registrationAccessToken The registration access token + * @param ClientRegistration $clientMetadata Updated client metadata + * + * @return ClientRegistrationResponse The updated client information + * + * @throws \RuntimeException If update fails + */ + public function update( + string $configurationEndpoint, + string $registrationAccessToken, + ClientRegistration $clientMetadata, + ): ClientRegistrationResponse { + try { + $body = json_encode($clientMetadata, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + $request = $this->requestFactory->createRequest('PUT', $configurationEndpoint) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json') + ->withHeader('Authorization', 'Bearer ' . $registrationAccessToken) + ->withBody($this->streamFactory->createStream($body)); + + $response = $this->httpClient->sendRequest($request); + $responseBody = (string) $response->getBody(); + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200) { + $this->logger->error('Client registration update failed', [ + 'endpoint' => $configurationEndpoint, + 'status' => $statusCode, + 'response' => $responseBody, + ]); + + $error = json_decode($responseBody, true); + throw new \RuntimeException(sprintf( + 'Client update failed: %s - %s', + $error['error'] ?? 'unknown_error', + $error['error_description'] ?? 'No description' + )); + } + + $data = json_decode($responseBody, true); + if (!is_array($data)) { + throw new \RuntimeException('Invalid update response'); + } + + return ClientRegistrationResponse::fromArray($data); + } catch (\JsonException $e) { + throw new \RuntimeException('Failed to encode/decode registration data: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Delete a client registration (RFC 7592). + * + * @param string $configurationEndpoint The client's registration configuration endpoint + * @param string $registrationAccessToken The registration access token + * + * @throws \RuntimeException If deletion fails + */ + public function delete(string $configurationEndpoint, string $registrationAccessToken): void + { + $request = $this->requestFactory->createRequest('DELETE', $configurationEndpoint) + ->withHeader('Authorization', 'Bearer ' . $registrationAccessToken); + + $response = $this->httpClient->sendRequest($request); + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 204 && $statusCode !== 200) { + $responseBody = (string) $response->getBody(); + $this->logger->error('Client deletion failed', [ + 'endpoint' => $configurationEndpoint, + 'status' => $statusCode, + 'response' => $responseBody, + ]); + + $error = json_decode($responseBody, true); + throw new \RuntimeException(sprintf( + 'Client deletion failed: %s - %s', + $error['error'] ?? 'unknown_error', + $error['error_description'] ?? 'No description' + )); + } + } + + /** + * Read current client registration (RFC 7592). + * + * @param string $configurationEndpoint The client's registration configuration endpoint + * @param string $registrationAccessToken The registration access token + * + * @return ClientRegistrationResponse Current client information + * + * @throws \RuntimeException If read fails + */ + public function read(string $configurationEndpoint, string $registrationAccessToken): ClientRegistrationResponse + { + $request = $this->requestFactory->createRequest('GET', $configurationEndpoint) + ->withHeader('Accept', 'application/json') + ->withHeader('Authorization', 'Bearer ' . $registrationAccessToken); + + $response = $this->httpClient->sendRequest($request); + $responseBody = (string) $response->getBody(); + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200) { + $this->logger->error('Client read failed', [ + 'endpoint' => $configurationEndpoint, + 'status' => $statusCode, + 'response' => $responseBody, + ]); + + $error = json_decode($responseBody, true); + throw new \RuntimeException(sprintf( + 'Client read failed: %s - %s', + $error['error'] ?? 'unknown_error', + $error['error_description'] ?? 'No description' + )); + } + + $data = json_decode($responseBody, true); + if (!is_array($data)) { + throw new \RuntimeException('Invalid read response'); + } + + return ClientRegistrationResponse::fromArray($data); + } +} + diff --git a/src/Server/Auth/IntrospectionTokenAuthenticator.php b/src/Server/Auth/IntrospectionTokenAuthenticator.php new file mode 100644 index 00000000..d99ebba7 --- /dev/null +++ b/src/Server/Auth/IntrospectionTokenAuthenticator.php @@ -0,0 +1,159 @@ + + */ +final class IntrospectionTokenAuthenticator implements TokenAuthenticatorInterface +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private StreamFactoryInterface $streamFactory; + + /** + * @param string $introspectionEndpoint URL of the token introspection endpoint + * @param string $clientId Client ID for authenticating to the introspection endpoint + * @param string $clientSecret Client secret for authentication + * @param string|null $expectedAudience Expected audience for the token + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + */ + public function __construct( + private readonly string $introspectionEndpoint, + private readonly string $clientId, + private readonly string $clientSecret, + private readonly ?string $expectedAudience = null, + private readonly LoggerInterface $logger = new NullLogger(), + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function authenticate(string $token, ?string $resource = null): AuthenticationResult + { + try { + $introspectionResult = $this->introspect($token); + + if (null === $introspectionResult) { + return AuthenticationResult::unauthenticated('invalid_token', 'Token introspection failed'); + } + + // Check if token is active + $active = $introspectionResult['active'] ?? false; + if (!$active) { + return AuthenticationResult::unauthenticated('invalid_token', 'Token is not active'); + } + + // Validate audience if expected + $expectedAudience = $resource ?? $this->expectedAudience; + if (null !== $expectedAudience) { + $aud = $introspectionResult['aud'] ?? null; + $audList = is_array($aud) ? $aud : [$aud]; + if (!in_array($expectedAudience, $audList, true)) { + $this->logger->warning('Token audience mismatch', [ + 'expected' => $expectedAudience, + 'actual' => $aud, + ]); + + return AuthenticationResult::unauthenticated('invalid_token', 'Token not intended for this resource'); + } + } + + // Build claims array from introspection response + $claims = []; + $relevantClaims = ['sub', 'iss', 'aud', 'scope', 'exp', 'iat', 'nbf', 'client_id', 'username']; + foreach ($relevantClaims as $claim) { + if (isset($introspectionResult[$claim])) { + $claims[$claim] = $introspectionResult[$claim]; + } + } + + return AuthenticationResult::authenticated($claims, ['token_type' => 'introspection']); + } catch (\Throwable $e) { + $this->logger->error('Token introspection failed', ['exception' => $e]); + + return AuthenticationResult::unauthenticated('invalid_token', 'Token validation failed'); + } + } + + /** + * Call the introspection endpoint. + * + * @return array|null + */ + private function introspect(string $token): ?array + { + try { + $authHeader = 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret); + $body = http_build_query(['token' => $token]); + + $request = $this->requestFactory->createRequest('POST', $this->introspectionEndpoint) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withHeader('Authorization', $authHeader) + ->withHeader('Accept', 'application/json') + ->withBody($this->streamFactory->createStream($body)); + + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 200) { + $this->logger->error('Introspection endpoint returned error', [ + 'endpoint' => $this->introspectionEndpoint, + 'status' => $response->getStatusCode(), + ]); + + return null; + } + + $responseBody = (string)$response->getBody(); + $result = json_decode($responseBody, true); + + if (!is_array($result)) { + $this->logger->error('Invalid introspection response', [ + 'response' => $responseBody, + ]); + + return null; + } + + return $result; + } catch (\Throwable $e) { + $this->logger->error('Failed to call introspection endpoint', [ + 'endpoint' => $this->introspectionEndpoint, + 'exception' => $e, + ]); + + return null; + } + } +} + diff --git a/src/Server/Auth/JwtTokenAuthenticator.php b/src/Server/Auth/JwtTokenAuthenticator.php new file mode 100644 index 00000000..e0276e4b --- /dev/null +++ b/src/Server/Auth/JwtTokenAuthenticator.php @@ -0,0 +1,511 @@ + + */ +final class JwtTokenAuthenticator implements TokenAuthenticatorInterface +{ + private const SUPPORTED_ALGORITHMS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512']; + + /** @var array|null */ + private ?array $jwksCache = null; + private ?int $jwksCacheTime = null; + + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + + /** + * @param string $jwksUri URI to fetch JWKS from + * @param string $issuer Expected issuer claim + * @param string|null $audience Expected audience claim (MCP server canonical URI) + * @param string[] $algorithms Allowed signing algorithms + * @param int $leeway Clock skew tolerance in seconds + * @param int $jwksCacheTtl How long to cache JWKS in seconds + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + */ + public function __construct( + private readonly string $jwksUri, + private readonly string $issuer, + private readonly ?string $audience = null, + private readonly array $algorithms = ['RS256'], + private readonly int $leeway = 60, + private readonly int $jwksCacheTtl = 3600, + private readonly LoggerInterface $logger = new NullLogger(), + private readonly ClockInterface $clock = new NativeClock(), + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ) { + foreach ($algorithms as $alg) { + if (!in_array($alg, self::SUPPORTED_ALGORITHMS, true)) { + throw new \InvalidArgumentException(sprintf( + 'Unsupported algorithm "%s". Supported: %s', + $alg, + implode(', ', self::SUPPORTED_ALGORITHMS) + )); + } + } + + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + } + + public function authenticate(string $token, ?string $resource = null): AuthenticationResult + { + try { + $parts = explode('.', $token); + if (count($parts) !== 3) { + return AuthenticationResult::unauthenticated('invalid_token', 'Malformed JWT'); + } + + // Decode header to get kid + $headerJson = $this->base64UrlDecode($parts[0]); + if (false === $headerJson) { + return AuthenticationResult::unauthenticated('invalid_token', 'Invalid JWT header encoding'); + } + + $header = json_decode($headerJson, true); + if (!is_array($header) || !isset($header['alg'])) { + return AuthenticationResult::unauthenticated('invalid_token', 'Invalid JWT header'); + } + + if (!in_array($header['alg'], $this->algorithms, true)) { + return AuthenticationResult::unauthenticated('invalid_token', 'Unsupported algorithm'); + } + + // Decode payload + $payloadJson = $this->base64UrlDecode($parts[1]); + if (false === $payloadJson) { + return AuthenticationResult::unauthenticated('invalid_token', 'Invalid JWT payload encoding'); + } + + $payload = json_decode($payloadJson, true); + if (!is_array($payload)) { + return AuthenticationResult::unauthenticated('invalid_token', 'Invalid JWT payload'); + } + + // Verify signature + $signature = $this->base64UrlDecode($parts[2]); + if (false === $signature) { + return AuthenticationResult::unauthenticated('invalid_token', 'Invalid JWT signature encoding'); + } + + $kid = $header['kid'] ?? null; + $key = $this->getPublicKey($kid, $header['alg']); + if (null === $key) { + return AuthenticationResult::unauthenticated('invalid_token', 'Unable to verify signature'); + } + + $dataToVerify = $parts[0] . '.' . $parts[1]; + if (!$this->verifySignature($dataToVerify, $signature, $key, $header['alg'])) { + return AuthenticationResult::unauthenticated('invalid_token', 'Invalid signature'); + } + + // Validate claims + $now = $this->clock->now()->getTimestamp(); + + // Check expiration + if (isset($payload['exp']) && is_numeric($payload['exp'])) { + if ($now > ((int)$payload['exp'] + $this->leeway)) { + return AuthenticationResult::unauthenticated('invalid_token', 'Token has expired'); + } + } + + // Check not before + if (isset($payload['nbf']) && is_numeric($payload['nbf'])) { + if ($now < ((int)$payload['nbf'] - $this->leeway)) { + return AuthenticationResult::unauthenticated('invalid_token', 'Token not yet valid'); + } + } + + // Check issued at + if (isset($payload['iat']) && is_numeric($payload['iat'])) { + if ($now < ((int)$payload['iat'] - $this->leeway)) { + return AuthenticationResult::unauthenticated('invalid_token', 'Token issued in the future'); + } + } + + // Verify issuer + if (!isset($payload['iss']) || $payload['iss'] !== $this->issuer) { + return AuthenticationResult::unauthenticated('invalid_token', 'Invalid issuer'); + } + + // Verify audience (MCP server canonical URI) + $expectedAudience = $resource ?? $this->audience; + if (null !== $expectedAudience) { + $aud = $payload['aud'] ?? null; + $audList = is_array($aud) ? $aud : [$aud]; + if (!in_array($expectedAudience, $audList, true)) { + $this->logger->warning( + 'Token audience mismatch', + [ + 'expected' => $expectedAudience, + 'actual' => $aud, + ] + ); + + return AuthenticationResult::unauthenticated('invalid_token', 'Token not intended for this resource'); + } + } + + return AuthenticationResult::authenticated($payload, ['token_type' => 'jwt']); + } catch (\Throwable $e) { + $this->logger->error('JWT authentication failed', ['exception' => $e]); + + return AuthenticationResult::unauthenticated('invalid_token', 'Token validation failed'); + } + } + + /** + * @return array|null + */ + private function getPublicKey(?string $kid, string $alg): ?array + { + $jwks = $this->fetchJwks(); + if (null === $jwks || !isset($jwks['keys']) || !is_array($jwks['keys'])) { + return null; + } + + foreach ($jwks['keys'] as $key) { + if (!is_array($key)) { + continue; + } + + // If kid is specified, match it + if (null !== $kid && isset($key['kid']) && $key['kid'] !== $kid) { + continue; + } + + // Check algorithm compatibility + if (isset($key['alg']) && $key['alg'] !== $alg) { + continue; + } + + // Check key type matches algorithm + $kty = $key['kty'] ?? null; + if (str_starts_with($alg, 'RS') && $kty !== 'RSA') { + continue; + } + if (str_starts_with($alg, 'ES') && $kty !== 'EC') { + continue; + } + + // Check key use + $use = $key['use'] ?? 'sig'; + if ($use !== 'sig') { + continue; + } + + return $key; + } + + return null; + } + + /** + * @return array|null + */ + private function fetchJwks(): ?array + { + // Check cache + if (null !== $this->jwksCache && null !== $this->jwksCacheTime) { + if (time() - $this->jwksCacheTime < $this->jwksCacheTtl) { + return $this->jwksCache; + } + } + + try { + $request = $this->requestFactory->createRequest('GET', $this->jwksUri) + ->withHeader('Accept', 'application/json'); + + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 200) { + $this->logger->error('Failed to fetch JWKS', [ + 'uri' => $this->jwksUri, + 'status' => $response->getStatusCode(), + ]); + + return null; + } + + $body = (string)$response->getBody(); + $jwks = json_decode($body, true); + + if (!is_array($jwks)) { + $this->logger->error('Invalid JWKS response', ['uri' => $this->jwksUri]); + + return null; + } + + $this->jwksCache = $jwks; + $this->jwksCacheTime = time(); + + return $jwks; + } catch (\Throwable $e) { + $this->logger->error('Error fetching JWKS', ['exception' => $e, 'uri' => $this->jwksUri]); + + return null; + } + } + + /** + * @param array $jwk + */ + private function verifySignature(string $data, string $signature, array $jwk, string $alg): bool + { + $publicKey = $this->jwkToPem($jwk); + if (null === $publicKey) { + return false; + } + + $algorithm = match ($alg) { + 'RS256' => OPENSSL_ALGO_SHA256, + 'RS384' => OPENSSL_ALGO_SHA384, + 'RS512' => OPENSSL_ALGO_SHA512, + 'ES256' => OPENSSL_ALGO_SHA256, + 'ES384' => OPENSSL_ALGO_SHA384, + 'ES512' => OPENSSL_ALGO_SHA512, + default => null, + }; + + if (null === $algorithm) { + return false; + } + + // For ECDSA, convert signature from JWT format to DER + if (str_starts_with($alg, 'ES')) { + $signature = $this->convertEcdsaSignatureToDer($signature, $alg); + if (null === $signature) { + return false; + } + } + + return 1 === openssl_verify($data, $signature, $publicKey, $algorithm); + } + + /** + * Convert JWK to PEM format. + * + * @param array $jwk + */ + private function jwkToPem(array $jwk): ?string + { + $kty = $jwk['kty'] ?? null; + + if ('RSA' === $kty) { + return $this->rsaJwkToPem($jwk); + } + + if ('EC' === $kty) { + return $this->ecJwkToPem($jwk); + } + + return null; + } + + /** + * @param array $jwk + */ + private function rsaJwkToPem(array $jwk): ?string + { + if (!isset($jwk['n'], $jwk['e'])) { + return null; + } + + $n = $this->base64UrlDecode($jwk['n']); + $e = $this->base64UrlDecode($jwk['e']); + + if (false === $n || false === $e) { + return null; + } + + // Build RSA public key in DER format + $modulus = "\x00" . $n; // Prepend 0x00 to ensure positive integer + $exponent = $e; + + $modulusLen = strlen($modulus); + $exponentLen = strlen($exponent); + + // Sequence of INTEGER (modulus) and INTEGER (exponent) + $rsaPublicKey = $this->asn1Sequence( + $this->asn1Integer($modulus) . + $this->asn1Integer($exponent) + ); + + // RSA algorithm identifier + $algorithmIdentifier = $this->asn1Sequence( + "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01" . // OID for RSA + "\x05\x00" // NULL + ); + + // SubjectPublicKeyInfo + $publicKeyInfo = $this->asn1Sequence( + $algorithmIdentifier . + $this->asn1BitString($rsaPublicKey) + ); + + return "-----BEGIN PUBLIC KEY-----\n" . + chunk_split(base64_encode($publicKeyInfo), 64, "\n") . + "-----END PUBLIC KEY-----"; + } + + /** + * @param array $jwk + */ + private function ecJwkToPem(array $jwk): ?string + { + if (!isset($jwk['x'], $jwk['y'], $jwk['crv'])) { + return null; + } + + $x = $this->base64UrlDecode($jwk['x']); + $y = $this->base64UrlDecode($jwk['y']); + + if (false === $x || false === $y) { + return null; + } + + // OID for the curve + $curveOid = match ($jwk['crv']) { + 'P-256' => "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07", + 'P-384' => "\x06\x05\x2b\x81\x04\x00\x22", + 'P-521' => "\x06\x05\x2b\x81\x04\x00\x23", + default => null, + }; + + if (null === $curveOid) { + return null; + } + + // Pad coordinates to curve size + $coordinateSize = match ($jwk['crv']) { + 'P-256' => 32, + 'P-384' => 48, + 'P-521' => 66, + default => 0, + }; + + $x = str_pad($x, $coordinateSize, "\x00", STR_PAD_LEFT); + $y = str_pad($y, $coordinateSize, "\x00", STR_PAD_LEFT); + + // Uncompressed point format: 0x04 || x || y + $publicKeyData = "\x04" . $x . $y; + + // Algorithm identifier for EC + $algorithmIdentifier = $this->asn1Sequence( + "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01" . // OID for EC public key + $curveOid + ); + + // SubjectPublicKeyInfo + $publicKeyInfo = $this->asn1Sequence( + $algorithmIdentifier . + $this->asn1BitString($publicKeyData) + ); + + return "-----BEGIN PUBLIC KEY-----\n" . + chunk_split(base64_encode($publicKeyInfo), 64, "\n") . + "-----END PUBLIC KEY-----"; + } + + private function convertEcdsaSignatureToDer(string $signature, string $alg): ?string + { + $componentSize = match ($alg) { + 'ES256' => 32, + 'ES384' => 48, + 'ES512' => 66, + default => 0, + }; + + if (strlen($signature) !== 2 * $componentSize) { + return null; + } + + $r = substr($signature, 0, $componentSize); + $s = substr($signature, $componentSize); + + // Remove leading zeros but keep at least one byte + $r = ltrim($r, "\x00") ?: "\x00"; + $s = ltrim($s, "\x00") ?: "\x00"; + + // Add leading zero if high bit is set (to keep positive) + if (ord($r[0]) & 0x80) { + $r = "\x00" . $r; + } + if (ord($s[0]) & 0x80) { + $s = "\x00" . $s; + } + + return $this->asn1Sequence( + $this->asn1Integer($r) . + $this->asn1Integer($s) + ); + } + + private function asn1Sequence(string $content): string + { + return "\x30" . $this->asn1Length(strlen($content)) . $content; + } + + private function asn1Integer(string $content): string + { + return "\x02" . $this->asn1Length(strlen($content)) . $content; + } + + private function asn1BitString(string $content): string + { + return "\x03" . $this->asn1Length(strlen($content) + 1) . "\x00" . $content; + } + + private function asn1Length(int $length): string + { + if ($length < 128) { + return chr($length); + } + if ($length < 256) { + return "\x81" . chr($length); + } + if ($length < 65536) { + return "\x82" . chr($length >> 8) . chr($length & 0xff); + } + + throw new \RuntimeException('ASN.1 length too long'); + } + + private function base64UrlDecode(string $input): string|false + { + $remainder = strlen($input) % 4; + if ($remainder) { + $input .= str_repeat('=', 4 - $remainder); + } + + return base64_decode(strtr($input, '-_', '+/')); + } +} + diff --git a/src/Server/Auth/OAuth2Configuration.php b/src/Server/Auth/OAuth2Configuration.php new file mode 100644 index 00000000..23984063 --- /dev/null +++ b/src/Server/Auth/OAuth2Configuration.php @@ -0,0 +1,107 @@ + + */ +final class OAuth2Configuration +{ + /** + * @param TokenAuthenticatorInterface $tokenAuthenticator Token validator + * @param ProtectedResourceMetadata $resourceMetadata Protected resource metadata (RFC 9728) + * @param string|null $resourceMetadataPath Custom path for metadata endpoint (defaults to /.well-known/oauth-protected-resource) + * @param string[] $publicPaths Paths that don't require authentication + * @param bool $validateAudience Whether to validate token audience matches resource URL + */ + public function __construct( + public readonly TokenAuthenticatorInterface $tokenAuthenticator, + public readonly ProtectedResourceMetadata $resourceMetadata, + public readonly ?string $resourceMetadataPath = null, + public readonly array $publicPaths = [], + public readonly bool $validateAudience = true, + ) { + } + + /** + * Get the expected audience for token validation. + * + * Returns the resource URL if audience validation is enabled, null otherwise. + */ + public function getExpectedAudience(): ?string + { + return $this->validateAudience ? $this->resourceMetadata->resource : null; + } + + /** + * Get the path where Protected Resource Metadata should be served. + */ + public function getResourceMetadataPath(): string + { + return $this->resourceMetadataPath ?? '/.well-known/oauth-protected-resource'; + } + + /** + * Get the full URL for the Protected Resource Metadata document. + */ + public function getResourceMetadataUrl(): string + { + $resource = rtrim($this->resourceMetadata->resource, '/'); + $path = $this->getResourceMetadataPath(); + + // If resource already contains path, handle appropriately + $parsed = parse_url($resource); + $basePath = $parsed['path'] ?? ''; + + if ('' !== $basePath && '/' !== $basePath) { + // Resource has a path component, metadata goes at /.well-known/oauth-protected-resource{path} + $scheme = $parsed['scheme'] ?? 'https'; + $host = $parsed['host'] ?? ''; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + + return "{$scheme}://{$host}{$port}/.well-known/oauth-protected-resource{$basePath}"; + } + + return $resource . $path; + } + + /** + * Check if a path should bypass authentication. + */ + public function isPublicPath(string $path): bool + { + // Protected Resource Metadata is always public + if ($path === $this->getResourceMetadataPath()) { + return true; + } + + // Handle paths starting with /.well-known/oauth-protected-resource + if (str_starts_with($path, '/.well-known/oauth-protected-resource')) { + return true; + } + + foreach ($this->publicPaths as $publicPath) { + if ($path === $publicPath) { + return true; + } + // Support wildcards + if (str_ends_with($publicPath, '*') && str_starts_with($path, rtrim($publicPath, '*'))) { + return true; + } + } + + return false; + } +} + diff --git a/src/Server/Auth/ProtectedResourceMetadata.php b/src/Server/Auth/ProtectedResourceMetadata.php new file mode 100644 index 00000000..3d51e0d1 --- /dev/null +++ b/src/Server/Auth/ProtectedResourceMetadata.php @@ -0,0 +1,86 @@ + + */ +class ProtectedResourceMetadata implements \JsonSerializable +{ + /** + * @param string $resource The protected resource identifier (canonical URI) + * @param string[] $authorizationServers List of authorization server issuer identifiers + * @param string[]|null $scopesSupported OAuth 2.0 scopes supported by the resource + * @param string[]|null $bearerMethodsSupported Bearer token methods supported + * @param string|null $resourceDocumentation URL of documentation + * @param string[]|null $resourceSigningAlgValuesSupported Signing algorithms supported + * @param string|null $resourceName Human-readable name + */ + public function __construct( + public readonly string $resource, + public readonly array $authorizationServers, + public readonly ?array $scopesSupported = null, + public readonly ?array $bearerMethodsSupported = ['header'], + public readonly ?string $resourceDocumentation = null, + public readonly ?array $resourceSigningAlgValuesSupported = null, + public readonly ?string $resourceName = null, + ) { + if (empty($authorizationServers)) { + throw new \InvalidArgumentException('At least one authorization server must be specified.'); + } + + // Validate resource URI format (RFC 3986) + if (!filter_var($resource, FILTER_VALIDATE_URL)) { + throw new \InvalidArgumentException('Resource must be a valid URI.'); + } + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'resource' => $this->resource, + 'authorization_servers' => $this->authorizationServers, + ]; + + if (null !== $this->scopesSupported) { + $data['scopes_supported'] = $this->scopesSupported; + } + + if (null !== $this->bearerMethodsSupported) { + $data['bearer_methods_supported'] = $this->bearerMethodsSupported; + } + + if (null !== $this->resourceDocumentation) { + $data['resource_documentation'] = $this->resourceDocumentation; + } + + if (null !== $this->resourceSigningAlgValuesSupported) { + $data['resource_signing_alg_values_supported'] = $this->resourceSigningAlgValuesSupported; + } + + if (null !== $this->resourceName) { + $data['resource_name'] = $this->resourceName; + } + + return $data; + } +} + diff --git a/src/Server/Auth/TokenAuthenticatorInterface.php b/src/Server/Auth/TokenAuthenticatorInterface.php new file mode 100644 index 00000000..6163cb34 --- /dev/null +++ b/src/Server/Auth/TokenAuthenticatorInterface.php @@ -0,0 +1,34 @@ + + */ +interface TokenAuthenticatorInterface +{ + /** + * Authenticate the given access token. + * + * @param string $token The access token to validate + * @param string|null $resource The resource URI (MCP server canonical URI) for audience validation + * + * @return AuthenticationResult The authentication result with claims if successful + */ + public function authenticate(string $token, ?string $resource = null): AuthenticationResult; +} + diff --git a/src/Server/Auth/WwwAuthenticateChallenge.php b/src/Server/Auth/WwwAuthenticateChallenge.php new file mode 100644 index 00000000..8735ee53 --- /dev/null +++ b/src/Server/Auth/WwwAuthenticateChallenge.php @@ -0,0 +1,164 @@ + + */ +final class WwwAuthenticateChallenge +{ + public const ERROR_INVALID_REQUEST = 'invalid_request'; + public const ERROR_INVALID_TOKEN = 'invalid_token'; + public const ERROR_INSUFFICIENT_SCOPE = 'insufficient_scope'; + + private ?string $realm = null; + private ?string $error = null; + private ?string $errorDescription = null; + private ?string $errorUri = null; + private ?string $scope = null; + private ?string $resourceMetadata = null; + + public function withRealm(string $realm): self + { + $clone = clone $this; + $clone->realm = $realm; + + return $clone; + } + + public function withError(string $error, ?string $description = null, ?string $uri = null): self + { + $clone = clone $this; + $clone->error = $error; + $clone->errorDescription = $description; + $clone->errorUri = $uri; + + return $clone; + } + + /** + * Set the scope parameter indicating required scopes. + * + * @param string|string[] $scope Space-delimited scope string or array of scopes + */ + public function withScope(string|array $scope): self + { + $clone = clone $this; + $clone->scope = is_array($scope) ? implode(' ', $scope) : $scope; + + return $clone; + } + + /** + * Set the resource_metadata parameter pointing to the Protected Resource Metadata document. + */ + public function withResourceMetadata(string $url): self + { + $clone = clone $this; + $clone->resourceMetadata = $url; + + return $clone; + } + + /** + * Build the WWW-Authenticate header value. + */ + public function build(): string + { + $parts = ['Bearer']; + + $params = []; + if (null !== $this->realm) { + $params[] = sprintf('realm="%s"', $this->escapeQuotedString($this->realm)); + } + + if (null !== $this->error) { + $params[] = sprintf('error="%s"', $this->error); + } + + if (null !== $this->errorDescription) { + $params[] = sprintf('error_description="%s"', $this->escapeQuotedString($this->errorDescription)); + } + + if (null !== $this->errorUri) { + $params[] = sprintf('error_uri="%s"', $this->escapeQuotedString($this->errorUri)); + } + + if (null !== $this->scope) { + $params[] = sprintf('scope="%s"', $this->scope); + } + + if (null !== $this->resourceMetadata) { + $params[] = sprintf('resource_metadata="%s"', $this->resourceMetadata); + } + + if (!empty($params)) { + return 'Bearer ' . implode(', ', $params); + } + + return 'Bearer'; + } + + /** + * Create a 401 Unauthorized challenge for missing/invalid token. + */ + public static function forUnauthorized( + string $resourceMetadataUrl, + ?string $scope = null, + ?string $errorDescription = null, + ): self { + $challenge = (new self()) + ->withResourceMetadata($resourceMetadataUrl); + + if (null !== $scope) { + $challenge = $challenge->withScope($scope); + } + + if (null !== $errorDescription) { + $challenge = $challenge->withError(self::ERROR_INVALID_TOKEN, $errorDescription); + } + + return $challenge; + } + + /** + * Create a 403 Forbidden challenge for insufficient scope. + * + * @param string|string[] $requiredScope + */ + public static function forInsufficientScope( + string $resourceMetadataUrl, + string|array $requiredScope, + ?string $errorDescription = null, + ): self { + return (new self()) + ->withError(self::ERROR_INSUFFICIENT_SCOPE, $errorDescription ?? 'Additional scope required') + ->withScope($requiredScope) + ->withResourceMetadata($resourceMetadataUrl); + } + + private function escapeQuotedString(string $value): string + { + // Escape backslashes first, then quotes + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + } + + public function __toString(): string + { + return $this->build(); + } +} + diff --git a/src/Server/Transport/OAuth2HttpTransport.php b/src/Server/Transport/OAuth2HttpTransport.php new file mode 100644 index 00000000..c0af0e96 --- /dev/null +++ b/src/Server/Transport/OAuth2HttpTransport.php @@ -0,0 +1,228 @@ + + */ +class OAuth2HttpTransport extends StreamableHttpTransport +{ + private ?AuthenticationResult $authResult = null; + + /** + * @param array $corsHeaders + */ + public function __construct( + private readonly OAuth2Configuration $authConfig, + ServerRequestInterface $request, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + array $corsHeaders = [], + ?LoggerInterface $logger = null, + ) { + parent::__construct($request, $responseFactory, $streamFactory, $corsHeaders, $logger); + } + + public function listen(): ResponseInterface + { + $path = $this->request->getUri()->getPath(); + $method = $this->request->getMethod(); + + // Handle Protected Resource Metadata endpoint + if ($this->isProtectedResourceMetadataRequest($path)) { + return $this->handleProtectedResourceMetadata(); + } + + // Skip authentication for OPTIONS (CORS preflight) + if ('OPTIONS' === $method) { + return parent::listen(); + } + + // Skip authentication for public paths + if ($this->authConfig->isPublicPath($path)) { + return parent::listen(); + } + + // Authenticate the request + $authResult = $this->authenticateRequest(); + if (!$authResult->authenticated) { + return $this->createUnauthorizedResponse($authResult); + } + + $this->authResult = $authResult; + + // Continue with normal request handling + return parent::listen(); + } + + /** + * Get the authentication result for the current request. + */ + public function getAuthenticationResult(): ?AuthenticationResult + { + return $this->authResult; + } + + /** + * Check if this is a request for Protected Resource Metadata. + */ + private function isProtectedResourceMetadataRequest(string $path): bool + { + // Check exact match + if ($path === $this->authConfig->getResourceMetadataPath()) { + return true; + } + + // Check well-known path with resource path component + if (str_starts_with($path, '/.well-known/oauth-protected-resource')) { + return true; + } + + return false; + } + + /** + * Handle Protected Resource Metadata request (RFC 9728). + */ + private function handleProtectedResourceMetadata(): ResponseInterface + { + $metadata = $this->authConfig->resourceMetadata; + + try { + $body = json_encode($metadata, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode protected resource metadata', ['exception' => $e]); + + return $this->withCorsHeaders( + $this->responseFactory->createResponse(500) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream('{"error": "internal_error"}')) + ); + } + + return $this->withCorsHeaders( + $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'public, max-age=3600') + ->withBody($this->streamFactory->createStream($body)) + ); + } + + /** + * Authenticate the incoming request using Bearer token. + */ + private function authenticateRequest(): AuthenticationResult + { + $authHeader = $this->request->getHeaderLine('Authorization'); + + if ('' === $authHeader) { + return AuthenticationResult::unauthenticated( + 'invalid_request', + 'Missing Authorization header' + ); + } + + // Extract Bearer token + if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) { + return AuthenticationResult::unauthenticated( + 'invalid_request', + 'Invalid Authorization header format' + ); + } + + $token = $matches[1]; + $expectedAudience = $this->authConfig->getExpectedAudience(); + + return $this->authConfig->tokenAuthenticator->authenticate($token, $expectedAudience); + } + + /** + * Create a 401 Unauthorized response with WWW-Authenticate header. + */ + private function createUnauthorizedResponse(AuthenticationResult $authResult): ResponseInterface + { + $metadataUrl = $this->authConfig->getResourceMetadataUrl(); + $scopesSupported = $this->authConfig->resourceMetadata->scopesSupported; + + $challenge = WwwAuthenticateChallenge::forUnauthorized( + $metadataUrl, + $scopesSupported ? implode(' ', $scopesSupported) : null, + $authResult->errorDescription + ); + + $this->logger->info('Returning 401 Unauthorized', [ + 'error' => $authResult->error, + 'error_description' => $authResult->errorDescription, + ]); + + return $this->withCorsHeaders( + $this->responseFactory->createResponse(401) + ->withHeader('WWW-Authenticate', $challenge->build()) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode([ + 'error' => $authResult->error ?? 'unauthorized', + 'error_description' => $authResult->errorDescription ?? 'Authorization required', + ], JSON_THROW_ON_ERROR))) + ); + } + + /** + * Create a 403 Forbidden response for insufficient scope. + * + * @param string[] $requiredScopes + */ + public function createForbiddenResponse(array $requiredScopes, ?string $description = null): ResponseInterface + { + $metadataUrl = $this->authConfig->getResourceMetadataUrl(); + + $challenge = WwwAuthenticateChallenge::forInsufficientScope( + $metadataUrl, + $requiredScopes, + $description + ); + + $this->logger->info('Returning 403 Forbidden', [ + 'required_scopes' => $requiredScopes, + 'description' => $description, + ]); + + return $this->withCorsHeaders( + $this->responseFactory->createResponse(403) + ->withHeader('WWW-Authenticate', $challenge->build()) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode([ + 'error' => 'insufficient_scope', + 'error_description' => $description ?? 'Additional scope required', + 'required_scopes' => $requiredScopes, + ], JSON_THROW_ON_ERROR))) + ); + } +} + diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 2b1e6869..a33fd982 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -27,20 +27,20 @@ */ class StreamableHttpTransport extends BaseTransport { - private ResponseFactoryInterface $responseFactory; - private StreamFactoryInterface $streamFactory; + protected ResponseFactoryInterface $responseFactory; + protected StreamFactoryInterface $streamFactory; private ?string $immediateResponse = null; private ?int $immediateStatusCode = null; /** @var array */ - private array $corsHeaders; + protected array $corsHeaders; /** * @param array $corsHeaders */ public function __construct( - private readonly ServerRequestInterface $request, + protected readonly ServerRequestInterface $request, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, array $corsHeaders = [], diff --git a/tests/Unit/Server/Auth/AuthenticationResultTest.php b/tests/Unit/Server/Auth/AuthenticationResultTest.php new file mode 100644 index 00000000..da7d5621 --- /dev/null +++ b/tests/Unit/Server/Auth/AuthenticationResultTest.php @@ -0,0 +1,86 @@ + 'user-123', + 'scope' => 'read write admin', + 'aud' => 'https://mcp.example.com', + ]; + + $result = AuthenticationResult::authenticated($claims, ['token_type' => 'jwt']); + + $this->assertTrue($result->authenticated); + $this->assertSame('user-123', $result->getSubject()); + $this->assertSame(['read', 'write', 'admin'], $result->getScopes()); + $this->assertNull($result->error); + $this->assertNull($result->errorDescription); + $this->assertSame(['token_type' => 'jwt'], $result->context); + } + + public function testUnauthenticatedResult(): void + { + $result = AuthenticationResult::unauthenticated('invalid_token', 'Token has expired'); + + $this->assertFalse($result->authenticated); + $this->assertSame('invalid_token', $result->error); + $this->assertSame('Token has expired', $result->errorDescription); + $this->assertEmpty($result->claims); + $this->assertNull($result->getSubject()); + $this->assertEmpty($result->getScopes()); + } + + public function testHasScope(): void + { + $result = AuthenticationResult::authenticated(['scope' => 'read write']); + + $this->assertTrue($result->hasScope('read')); + $this->assertTrue($result->hasScope('write')); + $this->assertFalse($result->hasScope('admin')); + } + + public function testHasAllScopes(): void + { + $result = AuthenticationResult::authenticated(['scope' => 'read write admin']); + + $this->assertTrue($result->hasAllScopes(['read', 'write'])); + $this->assertTrue($result->hasAllScopes(['read', 'write', 'admin'])); + $this->assertFalse($result->hasAllScopes(['read', 'delete'])); + } + + public function testScopesAsArray(): void + { + $result = AuthenticationResult::authenticated(['scope' => ['read', 'write']]); + + $this->assertSame(['read', 'write'], $result->getScopes()); + $this->assertTrue($result->hasScope('read')); + } + + public function testEmptyScopes(): void + { + $result = AuthenticationResult::authenticated(['sub' => 'user-123']); + + $this->assertEmpty($result->getScopes()); + $this->assertFalse($result->hasScope('read')); + } +} + diff --git a/tests/Unit/Server/Auth/AuthorizationServerMetadataTest.php b/tests/Unit/Server/Auth/AuthorizationServerMetadataTest.php new file mode 100644 index 00000000..e4e17307 --- /dev/null +++ b/tests/Unit/Server/Auth/AuthorizationServerMetadataTest.php @@ -0,0 +1,135 @@ + 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + ]; + + $metadata = AuthorizationServerMetadata::fromArray($data); + + $this->assertSame('https://auth.example.com', $metadata->issuer); + $this->assertSame('https://auth.example.com/authorize', $metadata->authorizationEndpoint); + $this->assertNull($metadata->tokenEndpoint); + $this->assertFalse($metadata->supportsPkce()); + } + + public function testFromArrayFull(): void + { + $data = [ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/.well-known/jwks.json', + 'registration_endpoint' => 'https://auth.example.com/register', + 'scopes_supported' => ['openid', 'profile', 'mcp:read'], + 'response_types_supported' => ['code', 'token'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => ['S256', 'plain'], + 'introspection_endpoint' => 'https://auth.example.com/introspect', + 'client_id_metadata_document_supported' => true, + ]; + + $metadata = AuthorizationServerMetadata::fromArray($data); + + $this->assertSame('https://auth.example.com/token', $metadata->tokenEndpoint); + $this->assertSame('https://auth.example.com/.well-known/jwks.json', $metadata->jwksUri); + $this->assertSame('https://auth.example.com/register', $metadata->registrationEndpoint); + $this->assertTrue($metadata->supportsDynamicRegistration()); + $this->assertTrue($metadata->supportsPkce()); + $this->assertTrue($metadata->supportsS256()); + $this->assertTrue($metadata->supportsClientIdMetadataDocument()); + } + + public function testSupportsPkce(): void + { + $metadata = AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'code_challenge_methods_supported' => ['S256'], + ]); + + $this->assertTrue($metadata->supportsPkce()); + $this->assertTrue($metadata->supportsS256()); + } + + public function testSupportsGrantType(): void + { + $metadata = AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + ]); + + $this->assertTrue($metadata->supportsGrantType('authorization_code')); + $this->assertTrue($metadata->supportsGrantType('refresh_token')); + $this->assertFalse($metadata->supportsGrantType('client_credentials')); + } + + public function testDefaultGrantTypes(): void + { + $metadata = AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + // No grant_types_supported - uses defaults per RFC 8414 + ]); + + $this->assertTrue($metadata->supportsGrantType('authorization_code')); + $this->assertTrue($metadata->supportsGrantType('implicit')); + $this->assertFalse($metadata->supportsGrantType('client_credentials')); + } + + public function testRequiresIssuer(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing issuer'); + + AuthorizationServerMetadata::fromArray([ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + ]); + } + + public function testRequiresAuthorizationEndpoint(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing authorization_endpoint'); + + AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + ]); + } + + public function testAdditionalFields(): void + { + $data = [ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'custom_field' => 'custom_value', + ]; + + $metadata = AuthorizationServerMetadata::fromArray($data); + + $this->assertSame('custom_value', $metadata->additionalFields['custom_field']); + } +} + diff --git a/tests/Unit/Server/Auth/ClientRegistrationResponseTest.php b/tests/Unit/Server/Auth/ClientRegistrationResponseTest.php new file mode 100644 index 00000000..0ef30a0c --- /dev/null +++ b/tests/Unit/Server/Auth/ClientRegistrationResponseTest.php @@ -0,0 +1,144 @@ + 'client-123', + ]; + + $response = ClientRegistrationResponse::fromArray($data); + + $this->assertSame('client-123', $response->clientId); + $this->assertNull($response->clientSecret); + $this->assertTrue($response->isPublicClient()); + } + + public function testFromArrayFull(): void + { + $data = [ + 'client_id' => 'client-123', + 'client_secret' => 'secret-456', + 'client_id_issued_at' => 1700000000, + 'client_secret_expires_at' => 1800000000, + 'registration_access_token' => 'reg-token-789', + 'registration_client_uri' => 'https://auth.example.com/clients/client-123', + 'redirect_uris' => ['http://localhost:3000/callback'], + 'client_name' => 'Test Client', + 'grant_types' => ['authorization_code'], + 'token_endpoint_auth_method' => 'client_secret_basic', + ]; + + $response = ClientRegistrationResponse::fromArray($data); + + $this->assertSame('client-123', $response->clientId); + $this->assertSame('secret-456', $response->clientSecret); + $this->assertSame(1700000000, $response->clientIdIssuedAt); + $this->assertSame(1800000000, $response->clientSecretExpiresAt); + $this->assertSame('reg-token-789', $response->registrationAccessToken); + $this->assertSame('https://auth.example.com/clients/client-123', $response->registrationClientUri); + $this->assertFalse($response->isPublicClient()); + $this->assertTrue($response->supportsManagement()); + } + + public function testIsSecretExpired(): void + { + // Secret not expired + $response = ClientRegistrationResponse::fromArray([ + 'client_id' => 'client-123', + 'client_secret' => 'secret', + 'client_secret_expires_at' => time() + 3600, // 1 hour from now + ]); + $this->assertFalse($response->isSecretExpired()); + + // Secret expired + $response = ClientRegistrationResponse::fromArray([ + 'client_id' => 'client-123', + 'client_secret' => 'secret', + 'client_secret_expires_at' => time() - 3600, // 1 hour ago + ]); + $this->assertTrue($response->isSecretExpired()); + + // Never expires (0) + $response = ClientRegistrationResponse::fromArray([ + 'client_id' => 'client-123', + 'client_secret' => 'secret', + 'client_secret_expires_at' => 0, + ]); + $this->assertFalse($response->isSecretExpired()); + } + + public function testSupportsManagement(): void + { + // Without management + $response = ClientRegistrationResponse::fromArray([ + 'client_id' => 'client-123', + ]); + $this->assertFalse($response->supportsManagement()); + + // With management + $response = ClientRegistrationResponse::fromArray([ + 'client_id' => 'client-123', + 'registration_access_token' => 'token', + 'registration_client_uri' => 'https://auth.example.com/clients/123', + ]); + $this->assertTrue($response->supportsManagement()); + } + + public function testToArray(): void + { + $response = new ClientRegistrationResponse( + clientId: 'client-123', + clientSecret: 'secret', + redirectUris: ['http://localhost:3000/callback'], + clientName: 'Test', + ); + + $array = $response->toArray(); + + $this->assertSame('client-123', $array['client_id']); + $this->assertSame('secret', $array['client_secret']); + $this->assertSame(['http://localhost:3000/callback'], $array['redirect_uris']); + } + + public function testAdditionalFields(): void + { + $data = [ + 'client_id' => 'client-123', + 'custom_field' => 'custom_value', + 'another_field' => 123, + ]; + + $response = ClientRegistrationResponse::fromArray($data); + + $this->assertSame('custom_value', $response->additionalFields['custom_field']); + $this->assertSame(123, $response->additionalFields['another_field']); + } + + public function testRequiresClientId(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing client_id'); + + ClientRegistrationResponse::fromArray([]); + } +} + diff --git a/tests/Unit/Server/Auth/ClientRegistrationTest.php b/tests/Unit/Server/Auth/ClientRegistrationTest.php new file mode 100644 index 00000000..5ea468d5 --- /dev/null +++ b/tests/Unit/Server/Auth/ClientRegistrationTest.php @@ -0,0 +1,133 @@ +jsonSerialize(); + + $this->assertSame(['http://localhost:3000/callback'], $json['redirect_uris']); + $this->assertSame(['authorization_code'], $json['grant_types']); + $this->assertSame(['code'], $json['response_types']); + $this->assertSame('none', $json['token_endpoint_auth_method']); + } + + public function testForPublicClient(): void + { + $registration = ClientRegistration::forPublicClient( + redirectUris: ['http://localhost:3000/callback', 'http://127.0.0.1:3000/callback'], + clientName: 'My MCP Client', + clientUri: 'https://example.com', + scope: 'mcp:read mcp:write', + ); + + $json = $registration->jsonSerialize(); + + $this->assertCount(2, $json['redirect_uris']); + $this->assertSame('My MCP Client', $json['client_name']); + $this->assertSame('https://example.com', $json['client_uri']); + $this->assertSame('mcp:read mcp:write', $json['scope']); + $this->assertSame('none', $json['token_endpoint_auth_method']); + $this->assertContains('refresh_token', $json['grant_types']); + } + + public function testForConfidentialClient(): void + { + $registration = ClientRegistration::forConfidentialClient( + redirectUris: ['https://app.example.com/callback'], + clientName: 'Server App', + tokenEndpointAuthMethod: 'client_secret_basic', + ); + + $json = $registration->jsonSerialize(); + + $this->assertSame('client_secret_basic', $json['token_endpoint_auth_method']); + $this->assertContains('client_credentials', $json['grant_types']); + } + + public function testRequiresRedirectUris(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one redirect URI is required'); + + new ClientRegistration(redirectUris: []); + } + + public function testFullRegistration(): void + { + $registration = new ClientRegistration( + redirectUris: ['https://app.example.com/callback'], + clientName: 'Full Client', + clientUri: 'https://example.com', + logoUri: 'https://example.com/logo.png', + grantTypes: ['authorization_code', 'refresh_token'], + responseTypes: ['code'], + scope: 'openid profile mcp:read', + contacts: ['admin@example.com'], + tosUri: 'https://example.com/tos', + policyUri: 'https://example.com/privacy', + softwareId: 'mcp-client-001', + softwareVersion: '1.0.0', + ); + + $json = $registration->jsonSerialize(); + + $this->assertSame('https://example.com/logo.png', $json['logo_uri']); + $this->assertSame(['admin@example.com'], $json['contacts']); + $this->assertSame('https://example.com/tos', $json['tos_uri']); + $this->assertSame('https://example.com/privacy', $json['policy_uri']); + $this->assertSame('mcp-client-001', $json['software_id']); + $this->assertSame('1.0.0', $json['software_version']); + } + + public function testFromArray(): void + { + $data = [ + 'redirect_uris' => ['http://localhost:3000/callback'], + 'client_name' => 'Test Client', + 'grant_types' => ['authorization_code'], + 'token_endpoint_auth_method' => 'none', + ]; + + $registration = ClientRegistration::fromArray($data); + + $this->assertSame(['http://localhost:3000/callback'], $registration->redirectUris); + $this->assertSame('Test Client', $registration->clientName); + } + + public function testJsonEncode(): void + { + $registration = ClientRegistration::forPublicClient( + redirectUris: ['http://localhost:3000/callback'], + clientName: 'Test', + ); + + $json = json_encode($registration, JSON_THROW_ON_ERROR); + $decoded = json_decode($json, true); + + $this->assertArrayHasKey('redirect_uris', $decoded); + $this->assertArrayHasKey('client_name', $decoded); + } +} + diff --git a/tests/Unit/Server/Auth/IntrospectionTokenAuthenticatorTest.php b/tests/Unit/Server/Auth/IntrospectionTokenAuthenticatorTest.php new file mode 100644 index 00000000..3d494e43 --- /dev/null +++ b/tests/Unit/Server/Auth/IntrospectionTokenAuthenticatorTest.php @@ -0,0 +1,219 @@ +createAuthenticator([ + 'active' => true, + 'sub' => 'user-123', + 'scope' => 'read write', + 'client_id' => 'my-client', + ]); + + $result = $authenticator->authenticate('valid-token'); + + $this->assertTrue($result->authenticated); + $this->assertSame('user-123', $result->getSubject()); + $this->assertSame(['read', 'write'], $result->getScopes()); + } + + public function testRejectsInactiveToken(): void + { + $authenticator = $this->createAuthenticator([ + 'active' => false, + ]); + + $result = $authenticator->authenticate('expired-token'); + + $this->assertFalse($result->authenticated); + $this->assertSame('invalid_token', $result->error); + } + + public function testRejectsEmptyToken(): void + { + $authenticator = $this->createAuthenticator([]); + + $result = $authenticator->authenticate(''); + + $this->assertFalse($result->authenticated); + $this->assertSame('invalid_token', $result->error); + } + + public function testHandlesHttpError(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockStreamFactory = $this->createMock(StreamFactoryInterface::class); + + $mockRequest = $this->createMock(RequestInterface::class); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockRequest->method('withBody')->willReturnSelf(); + + $mockStream = $this->createMock(StreamInterface::class); + $mockStreamFactory->method('createStream')->willReturn($mockStream); + + $mockResponse = $this->createMock(ResponseInterface::class); + $mockResponse->method('getStatusCode')->willReturn(500); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + $authenticator = new IntrospectionTokenAuthenticator( + introspectionEndpoint: 'https://auth.example.com/introspect', + clientId: 'resource-server', + clientSecret: 'secret', + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $mockStreamFactory, + ); + + $result = $authenticator->authenticate('some-token'); + + $this->assertFalse($result->authenticated); + } + + public function testHandlesInvalidJsonResponse(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockStreamFactory = $this->createMock(StreamFactoryInterface::class); + + $mockRequest = $this->createMock(RequestInterface::class); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockRequest->method('withBody')->willReturnSelf(); + + $mockStream = $this->createMock(StreamInterface::class); + $mockStreamFactory->method('createStream')->willReturn($mockStream); + + $bodyStream = $this->createMock(StreamInterface::class); + $bodyStream->method('__toString')->willReturn('not valid json'); + + $mockResponse = $this->createMock(ResponseInterface::class); + $mockResponse->method('getStatusCode')->willReturn(200); + $mockResponse->method('getBody')->willReturn($bodyStream); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + $authenticator = new IntrospectionTokenAuthenticator( + introspectionEndpoint: 'https://auth.example.com/introspect', + clientId: 'resource-server', + clientSecret: 'secret', + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $mockStreamFactory, + ); + + $result = $authenticator->authenticate('some-token'); + + $this->assertFalse($result->authenticated); + } + + public function testIncludesBasicAuth(): void + { + $authHeader = null; + + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockStreamFactory = $this->createMock(StreamFactoryInterface::class); + + $mockRequest = $this->createMock(RequestInterface::class); + $mockRequest->method('withHeader')->willReturnCallback(function ($name, $value) use ($mockRequest, &$authHeader) { + if ('Authorization' === $name) { + $authHeader = $value; + } + + return $mockRequest; + }); + $mockRequest->method('withBody')->willReturnSelf(); + + $mockStreamFactory->method('createStream')->willReturnCallback(function ($content) { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn($content); + + return $stream; + }); + + $bodyStream = $this->createMock(StreamInterface::class); + $bodyStream->method('__toString')->willReturn(json_encode(['active' => false])); + + $mockResponse = $this->createMock(ResponseInterface::class); + $mockResponse->method('getStatusCode')->willReturn(200); + $mockResponse->method('getBody')->willReturn($bodyStream); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + $authenticator = new IntrospectionTokenAuthenticator( + introspectionEndpoint: 'https://auth.example.com/introspect', + clientId: 'resource-server', + clientSecret: 'secret', + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $mockStreamFactory, + ); + + $authenticator->authenticate('test-token'); + + $expectedAuth = 'Basic ' . base64_encode('resource-server:secret'); + $this->assertSame($expectedAuth, $authHeader); + } + + private function createAuthenticator(array $introspectionResponse): IntrospectionTokenAuthenticator + { + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockStreamFactory = $this->createMock(StreamFactoryInterface::class); + + $mockRequest = $this->createMock(RequestInterface::class); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockRequest->method('withBody')->willReturnSelf(); + + $mockStream = $this->createMock(StreamInterface::class); + $mockStreamFactory->method('createStream')->willReturn($mockStream); + + $bodyStream = $this->createMock(StreamInterface::class); + $bodyStream->method('__toString')->willReturn(json_encode($introspectionResponse)); + + $mockResponse = $this->createMock(ResponseInterface::class); + $mockResponse->method('getStatusCode')->willReturn(200); + $mockResponse->method('getBody')->willReturn($bodyStream); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + return new IntrospectionTokenAuthenticator( + introspectionEndpoint: 'https://auth.example.com/introspect', + clientId: 'resource-server', + clientSecret: 'secret', + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $mockStreamFactory, + ); + } +} + diff --git a/tests/Unit/Server/Auth/JwtTokenAuthenticatorTest.php b/tests/Unit/Server/Auth/JwtTokenAuthenticatorTest.php new file mode 100644 index 00000000..a52e5387 --- /dev/null +++ b/tests/Unit/Server/Auth/JwtTokenAuthenticatorTest.php @@ -0,0 +1,150 @@ +createAuthenticator(); + + // Not a JWT at all + $result = $authenticator->authenticate('not-a-jwt'); + $this->assertFalse($result->authenticated); + $this->assertSame('invalid_token', $result->error); + + // Only two parts + $result = $authenticator->authenticate('part1.part2'); + $this->assertFalse($result->authenticated); + + // Four parts + $result = $authenticator->authenticate('part1.part2.part3.part4'); + $this->assertFalse($result->authenticated); + } + + public function testRejectsInvalidBase64Header(): void + { + $authenticator = $this->createAuthenticator(); + + // Invalid base64 in header + $result = $authenticator->authenticate('!!!.eyJ0ZXN0IjoxfQ.signature'); + $this->assertFalse($result->authenticated); + $this->assertStringContainsString('header', $result->errorDescription ?? ''); + } + + public function testRejectsUnsupportedAlgorithm(): void + { + $authenticator = $this->createAuthenticator(['RS256']); + + // Create JWT with HS256 algorithm + $header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + $payload = base64_encode(json_encode(['sub' => 'user'])); + $token = str_replace(['+', '/', '='], ['-', '_', ''], "{$header}.{$payload}.signature"); + + $result = $authenticator->authenticate($token); + $this->assertFalse($result->authenticated); + $this->assertSame('invalid_token', $result->error); + } + + public function testRejectsInvalidPayload(): void + { + $authenticator = $this->createAuthenticator(); + + // Valid header, invalid payload + $header = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $payload = '!!!invalid!!!'; + $token = "{$header}.{$payload}.signature"; + + $result = $authenticator->authenticate($token); + $this->assertFalse($result->authenticated); + } + + public function testUnsupportedAlgorithmThrowsInConstructor(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported algorithm'); + + new JwtTokenAuthenticator( + jwksUri: 'https://auth.example.com/.well-known/jwks.json', + issuer: 'https://auth.example.com', + algorithms: ['HS256'], // Not supported + ); + } + + public function testSupportedAlgorithms(): void + { + // Should not throw + $authenticator = new JwtTokenAuthenticator( + jwksUri: 'https://auth.example.com/.well-known/jwks.json', + issuer: 'https://auth.example.com', + algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'], + ); + + $this->assertInstanceOf(JwtTokenAuthenticator::class, $authenticator); + } + + private function createAuthenticator(array $algorithms = ['RS256']): JwtTokenAuthenticator + { + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + + // Mock JWKS response + $mockRequest = $this->createMock(RequestInterface::class); + $mockRequest->method('withHeader')->willReturnSelf(); + + $mockStream = $this->createMock(StreamInterface::class); + $mockStream->method('__toString')->willReturn(json_encode([ + 'keys' => [ + [ + 'kty' => 'RSA', + 'use' => 'sig', + 'kid' => 'test-key', + 'alg' => 'RS256', + 'n' => 'test-modulus', + 'e' => 'AQAB', + ], + ], + ])); + + $mockResponse = $this->createMock(ResponseInterface::class); + $mockResponse->method('getStatusCode')->willReturn(200); + $mockResponse->method('getBody')->willReturn($mockStream); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + return new JwtTokenAuthenticator( + jwksUri: 'https://auth.example.com/.well-known/jwks.json', + issuer: 'https://auth.example.com', + algorithms: $algorithms, + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + ); + } + + private function base64UrlEncode(string $data): string + { + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data)); + } +} + diff --git a/tests/Unit/Server/Auth/OAuth2ConfigurationTest.php b/tests/Unit/Server/Auth/OAuth2ConfigurationTest.php new file mode 100644 index 00000000..2132cdb6 --- /dev/null +++ b/tests/Unit/Server/Auth/OAuth2ConfigurationTest.php @@ -0,0 +1,125 @@ +mockAuthenticator = $this->createMock(TokenAuthenticatorInterface::class); + $this->metadata = new ProtectedResourceMetadata( + resource: 'https://mcp.example.com', + authorizationServers: ['https://auth.example.com'], + ); + } + + public function testDefaultResourceMetadataPath(): void + { + $config = new OAuth2Configuration( + tokenAuthenticator: $this->mockAuthenticator, + resourceMetadata: $this->metadata, + ); + + $this->assertSame('/.well-known/oauth-protected-resource', $config->getResourceMetadataPath()); + } + + public function testCustomResourceMetadataPath(): void + { + $config = new OAuth2Configuration( + tokenAuthenticator: $this->mockAuthenticator, + resourceMetadata: $this->metadata, + resourceMetadataPath: '/custom/metadata', + ); + + $this->assertSame('/custom/metadata', $config->getResourceMetadataPath()); + } + + public function testResourceMetadataUrl(): void + { + $config = new OAuth2Configuration( + tokenAuthenticator: $this->mockAuthenticator, + resourceMetadata: $this->metadata, + ); + + $this->assertSame( + 'https://mcp.example.com/.well-known/oauth-protected-resource', + $config->getResourceMetadataUrl() + ); + } + + public function testResourceMetadataUrlWithPath(): void + { + $metadata = new ProtectedResourceMetadata( + resource: 'https://mcp.example.com/api/v1', + authorizationServers: ['https://auth.example.com'], + ); + + $config = new OAuth2Configuration( + tokenAuthenticator: $this->mockAuthenticator, + resourceMetadata: $metadata, + ); + + $url = $config->getResourceMetadataUrl(); + $this->assertStringContainsString('/.well-known/oauth-protected-resource', $url); + } + + public function testIsPublicPathForMetadataEndpoint(): void + { + $config = new OAuth2Configuration( + tokenAuthenticator: $this->mockAuthenticator, + resourceMetadata: $this->metadata, + ); + + $this->assertTrue($config->isPublicPath('/.well-known/oauth-protected-resource')); + $this->assertTrue($config->isPublicPath('/.well-known/oauth-protected-resource/some/path')); + } + + public function testIsPublicPathForCustomPaths(): void + { + $config = new OAuth2Configuration( + tokenAuthenticator: $this->mockAuthenticator, + resourceMetadata: $this->metadata, + publicPaths: ['/health', '/api/public/*'], + ); + + $this->assertTrue($config->isPublicPath('/health')); + $this->assertTrue($config->isPublicPath('/api/public/status')); + $this->assertTrue($config->isPublicPath('/api/public/anything')); + $this->assertFalse($config->isPublicPath('/api/private')); + $this->assertFalse($config->isPublicPath('/')); + } + + public function testProtectedPathsRequireAuth(): void + { + $config = new OAuth2Configuration( + tokenAuthenticator: $this->mockAuthenticator, + resourceMetadata: $this->metadata, + ); + + $this->assertFalse($config->isPublicPath('/')); + $this->assertFalse($config->isPublicPath('/mcp')); + $this->assertFalse($config->isPublicPath('/api/tools')); + } +} + diff --git a/tests/Unit/Server/Auth/ProtectedResourceMetadataTest.php b/tests/Unit/Server/Auth/ProtectedResourceMetadataTest.php new file mode 100644 index 00000000..cf2a332d --- /dev/null +++ b/tests/Unit/Server/Auth/ProtectedResourceMetadataTest.php @@ -0,0 +1,101 @@ +jsonSerialize(); + + $this->assertSame('https://mcp.example.com', $json['resource']); + $this->assertSame(['https://auth.example.com'], $json['authorization_servers']); + $this->assertArrayHasKey('bearer_methods_supported', $json); + $this->assertSame(['header'], $json['bearer_methods_supported']); + } + + public function testFullMetadata(): void + { + $metadata = new ProtectedResourceMetadata( + resource: 'https://mcp.example.com', + authorizationServers: ['https://auth1.example.com', 'https://auth2.example.com'], + scopesSupported: ['read', 'write', 'admin'], + bearerMethodsSupported: ['header'], + resourceDocumentation: 'https://docs.example.com', + resourceName: 'MCP Server', + ); + + $json = $metadata->jsonSerialize(); + + $this->assertSame('https://mcp.example.com', $json['resource']); + $this->assertCount(2, $json['authorization_servers']); + $this->assertSame(['read', 'write', 'admin'], $json['scopes_supported']); + $this->assertSame('https://docs.example.com', $json['resource_documentation']); + $this->assertSame('MCP Server', $json['resource_name']); + } + + public function testRequiresAtLeastOneAuthorizationServer(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one authorization server must be specified'); + + new ProtectedResourceMetadata( + resource: 'https://mcp.example.com', + authorizationServers: [], + ); + } + + public function testJsonEncode(): void + { + $metadata = new ProtectedResourceMetadata( + resource: 'https://mcp.example.com', + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['read', 'write'], + ); + + $json = json_encode($metadata, JSON_THROW_ON_ERROR); + $decoded = json_decode($json, true); + + $this->assertSame('https://mcp.example.com', $decoded['resource']); + $this->assertSame(['https://auth.example.com'], $decoded['authorization_servers']); + $this->assertSame(['read', 'write'], $decoded['scopes_supported']); + } + + public function testOmitsNullValues(): void + { + $metadata = new ProtectedResourceMetadata( + resource: 'https://mcp.example.com', + authorizationServers: ['https://auth.example.com'], + scopesSupported: null, + resourceDocumentation: null, + resourceName: null, + ); + + $json = $metadata->jsonSerialize(); + + $this->assertArrayNotHasKey('scopes_supported', $json); + $this->assertArrayNotHasKey('resource_documentation', $json); + $this->assertArrayNotHasKey('resource_name', $json); + } +} + diff --git a/tests/Unit/Server/Auth/WwwAuthenticateChallengeTest.php b/tests/Unit/Server/Auth/WwwAuthenticateChallengeTest.php new file mode 100644 index 00000000..26f1817c --- /dev/null +++ b/tests/Unit/Server/Auth/WwwAuthenticateChallengeTest.php @@ -0,0 +1,131 @@ +assertSame('Bearer', $challenge->build()); + } + + public function testChallengeWithRealm(): void + { + $challenge = (new WwwAuthenticateChallenge()) + ->withRealm('MCP Server'); + + $this->assertSame('Bearer realm="MCP Server"', $challenge->build()); + } + + public function testChallengeWithError(): void + { + $challenge = (new WwwAuthenticateChallenge()) + ->withError('invalid_token', 'Token has expired'); + + $this->assertSame( + 'Bearer error="invalid_token", error_description="Token has expired"', + $challenge->build() + ); + } + + public function testChallengeWithScope(): void + { + $challenge = (new WwwAuthenticateChallenge()) + ->withScope('read write'); + + $this->assertSame('Bearer scope="read write"', $challenge->build()); + } + + public function testChallengeWithScopeArray(): void + { + $challenge = (new WwwAuthenticateChallenge()) + ->withScope(['read', 'write', 'admin']); + + $this->assertSame('Bearer scope="read write admin"', $challenge->build()); + } + + public function testChallengeWithResourceMetadata(): void + { + $challenge = (new WwwAuthenticateChallenge()) + ->withResourceMetadata('https://mcp.example.com/.well-known/oauth-protected-resource'); + + $this->assertSame( + 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + $challenge->build() + ); + } + + public function testForUnauthorized(): void + { + $challenge = WwwAuthenticateChallenge::forUnauthorized( + 'https://mcp.example.com/.well-known/oauth-protected-resource', + 'read write', + 'Missing token' + ); + + $result = $challenge->build(); + + $this->assertStringContainsString('error="invalid_token"', $result); + $this->assertStringContainsString('scope="read write"', $result); + $this->assertStringContainsString('resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', $result); + } + + public function testForInsufficientScope(): void + { + $challenge = WwwAuthenticateChallenge::forInsufficientScope( + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ['admin', 'write'], + 'Admin access required' + ); + + $result = $challenge->build(); + + $this->assertStringContainsString('error="insufficient_scope"', $result); + $this->assertStringContainsString('scope="admin write"', $result); + $this->assertStringContainsString('error_description="Admin access required"', $result); + } + + public function testToString(): void + { + $challenge = (new WwwAuthenticateChallenge()) + ->withRealm('Test'); + + $this->assertSame('Bearer realm="Test"', (string) $challenge); + } + + public function testEscapesQuotes(): void + { + $challenge = (new WwwAuthenticateChallenge()) + ->withRealm('Test "realm"'); + + $this->assertSame('Bearer realm="Test \"realm\""', $challenge->build()); + } + + public function testImmutability(): void + { + $original = new WwwAuthenticateChallenge(); + $withRealm = $original->withRealm('Test'); + + $this->assertNotSame($original, $withRealm); + $this->assertSame('Bearer', $original->build()); + $this->assertSame('Bearer realm="Test"', $withRealm->build()); + } +} + diff --git a/tests/Unit/Server/Transport/OAuth2HttpTransportTest.php b/tests/Unit/Server/Transport/OAuth2HttpTransportTest.php new file mode 100644 index 00000000..f751f4a4 --- /dev/null +++ b/tests/Unit/Server/Transport/OAuth2HttpTransportTest.php @@ -0,0 +1,217 @@ +factory = new Psr17Factory(); + $this->metadata = new ProtectedResourceMetadata( + resource: 'https://mcp.example.com', + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + ); + } + + public function testReturnsProtectedResourceMetadata(): void + { + $request = new ServerRequest('GET', '/.well-known/oauth-protected-resource'); + + $transport = $this->createTransport($request); + $response = $transport->listen(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('https://mcp.example.com', $body['resource']); + $this->assertSame(['https://auth.example.com'], $body['authorization_servers']); + } + + public function testReturns401ForMissingToken(): void + { + $request = new ServerRequest('POST', '/mcp'); + + $transport = $this->createTransport($request); + $response = $transport->listen(); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertTrue($response->hasHeader('WWW-Authenticate')); + + $wwwAuth = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringStartsWith('Bearer', $wwwAuth); + $this->assertStringContainsString('resource_metadata', $wwwAuth); + } + + public function testReturns401ForInvalidToken(): void + { + $mockAuthenticator = $this->createMock(TokenAuthenticatorInterface::class); + $mockAuthenticator->method('authenticate') + ->willReturn(AuthenticationResult::unauthenticated('invalid_token', 'Token expired')); + + $request = (new ServerRequest('POST', '/mcp')) + ->withHeader('Authorization', 'Bearer invalid-token'); + + $transport = $this->createTransport($request, $mockAuthenticator); + $response = $transport->listen(); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertStringContainsString('error="invalid_token"', $response->getHeaderLine('WWW-Authenticate')); + } + + public function testAllowsAuthenticatedRequest(): void + { + $mockAuthenticator = $this->createMock(TokenAuthenticatorInterface::class); + $mockAuthenticator->method('authenticate') + ->willReturn(AuthenticationResult::authenticated([ + 'sub' => 'user-123', + 'scope' => 'mcp:read mcp:write', + ])); + + // Create a valid JSON-RPC request + $jsonBody = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => [], + 'clientInfo' => ['name' => 'Test', 'version' => '1.0'], + ], + ]); + + $request = (new ServerRequest('POST', '/mcp')) + ->withHeader('Authorization', 'Bearer valid-token') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream($jsonBody)); + + $transport = $this->createTransport($request, $mockAuthenticator); + $response = $transport->listen(); + + // Should not be 401/403 + $this->assertNotEquals(401, $response->getStatusCode()); + $this->assertNotEquals(403, $response->getStatusCode()); + + // Should have auth result + $authResult = $transport->getAuthenticationResult(); + $this->assertNotNull($authResult); + $this->assertTrue($authResult->authenticated); + $this->assertSame('user-123', $authResult->getSubject()); + } + + public function testOptionsRequestBypassesAuth(): void + { + $request = new ServerRequest('OPTIONS', '/mcp'); + + $transport = $this->createTransport($request); + $response = $transport->listen(); + + // OPTIONS should not require auth + $this->assertNotEquals(401, $response->getStatusCode()); + } + + public function testCreateForbiddenResponse(): void + { + $mockAuthenticator = $this->createMock(TokenAuthenticatorInterface::class); + $mockAuthenticator->method('authenticate') + ->willReturn(AuthenticationResult::authenticated(['sub' => 'user-123', 'scope' => 'read'])); + + $request = (new ServerRequest('POST', '/mcp')) + ->withHeader('Authorization', 'Bearer token'); + + $config = new OAuth2Configuration( + tokenAuthenticator: $mockAuthenticator, + resourceMetadata: $this->metadata, + ); + + $transport = new OAuth2HttpTransport( + $config, + $request, + $this->factory, + $this->factory, + ); + + $response = $transport->createForbiddenResponse(['admin', 'write'], 'Admin access required'); + + $this->assertSame(403, $response->getStatusCode()); + $wwwAuth = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('insufficient_scope', $wwwAuth); + $this->assertStringContainsString('admin write', $wwwAuth); + } + + public function testPublicPathBypassesAuth(): void + { + $mockAuthenticator = $this->createMock(TokenAuthenticatorInterface::class); + + $config = new OAuth2Configuration( + tokenAuthenticator: $mockAuthenticator, + resourceMetadata: $this->metadata, + publicPaths: ['/health', '/public/*'], + ); + + $request = new ServerRequest('GET', '/health'); + + $transport = new OAuth2HttpTransport( + $config, + $request, + $this->factory, + $this->factory, + ); + + $response = $transport->listen(); + + // Health check should not require auth + $this->assertNotEquals(401, $response->getStatusCode()); + } + + private function createTransport( + ServerRequest $request, + ?TokenAuthenticatorInterface $authenticator = null, + ): OAuth2HttpTransport { + if (null === $authenticator) { + $authenticator = $this->createMock(TokenAuthenticatorInterface::class); + $authenticator->method('authenticate') + ->willReturn(AuthenticationResult::unauthenticated('invalid_token', 'No token')); + } + + $config = new OAuth2Configuration( + tokenAuthenticator: $authenticator, + resourceMetadata: $this->metadata, + ); + + return new OAuth2HttpTransport( + $config, + $request, + $this->factory, + $this->factory, + ); + } +} +